Press Register and your browser asks the authenticator (Windows Hello, Touch ID, Android, a security key, or a phone over QR) to mint a key pair for this site. Press Sign in and it signs a fresh challenge with that key. Every byte that comes back is decoded below, and the signature is checked with WebCrypto, in this tab. Nothing leaves the browser: the public key sits in this browser's localStorage, the private key never leaves the authenticator, and the passkey is bound to lab.gcamara.dev, so it is useless on any other site.
Checking WebAuthn support…
Registration calls navigator.credentials.create() with a random 32-byte challenge, rp.id set to this hostname, a throwaway user (visitor@lab, random 16-byte id) and three accepted algorithms in order of preference: ES256 (-7), RS256 (-257), EdDSA (-8). The authenticator takes the first one it supports and returns a new public key. Sign-in calls navigator.credentials.get() with a fresh challenge and the stored credential id in allowCredentials. The discoverable variant leaves that list out; the authenticator then shows which passkeys it holds for this site and returns a userHandle so the server knows who signed.
authenticatorData is a byte string the authenticator builds itself: SHA-256 of the rpId (32 bytes), one flags byte, a 4-byte big-endian signature counter, and on registration the attested credential data (16-byte AAGUID, a 2-byte length, the credential id, then the COSE public key as CBOR). The browser writes clientDataJSON on its own, with the challenge, the origin and the ceremony type. The authenticator never reads it; it signs authenticatorData || SHA-256(clientDataJSON). So the server can trust the origin without trusting the authenticator to know it: the browser wrote it, and the authenticator signed a hash of what the browser wrote.
The COSE key from registration becomes a JWK and goes through crypto.subtle.importKey. ES256 keys import as ECDSA P-256; the DER SEQUENCE { INTEGER r, INTEGER s } signature the authenticator returns has to be flattened to 64 raw bytes, r then s, because that is the only shape WebCrypto's ECDSA verify accepts. RS256 keys import as RSASSA-PKCS1-v1_5 and the signature is used as is. EdDSA keys map to the Ed25519 algorithm, which not every browser has in WebCrypto yet; when the import throws, the page says so instead of guessing. cbor.js is the whole CBOR decoder: ints, byte and text strings, arrays, maps, and the simple values. That covers the attestation object and any COSE key.
Each assertion carries the authenticator's signature counter. A relying party stores the last value and expects the next one to be higher; one that does not move suggests the key was copied. Synced passkeys usually report 0 every time, because the same key legitimately lives on several devices, so 0 means "no counter", not "clone". The page shows the previous and current values and which of those cases you are in.
This page plays the relying party, so it keeps what a server would keep, in localStorage: credential id, public key as JWK, algorithm, last counter, creation time. No secret exists on this side. Forget all clears that list and nothing else; the passkeys themselves stay on the authenticator until you delete them in the OS (Windows Settings, iCloud Keychain, Google Password Manager, or the key's own tool).
attestation: "none" asks the authenticator not to identify its make, so the AAGUID is normally all zeros and there is no certificate chain to walk. That is also what most real sites request; attestation matters for enterprise policy, not for logging in. A cancelled prompt, a missing authenticator or a browser without WebAuthn shows up as one sentence in the box above rather than a stack trace.