WebAuthn — a working engineer's reference.
WebAuthn replaces shared-secret passwords with public-key cryptography bound to the relying-party origin. The credential lives on the authenticator (smart card, USB key, platform TPM, secure enclave). The server never sees a secret. Origin binding is what kills the entire phishing class.
Two specs working in concert
WebAuthn is a Web API. CTAP2 is the wire protocol that backs it. WebAuthn is what your relying-party JavaScript calls (navigator.credentials.create() for registration, .get() for authentication). CTAP2 is what the browser speaks to a roaming authenticator over USB-HID, NFC, or BLE. Platform authenticators (Windows Hello, Apple Touch ID, Android Keystore) skip CTAP and go through OS-native APIs — but the WebAuthn surface is identical from the RP's perspective.
Registration ceremony
Server generates a 32-byte random challenge, picks an RP ID, sets user.id (opaque), pubKeyCredParams (e.g. ES256, EdDSA), and an attestation policy (none / direct / enterprise).
Browser serialises the type, origin, and challenge into a JSON object. The SHA-256 of these bytes is what the authenticator signs.
Authenticator generates a fresh key, stores private side internally, returns public key inside an attestationObject (CBOR with fmt, attStmt, authData).
RP verifies clientData, computes SHA-256(rpId), checks rpIdHash, validates attestation if required, persists credentialId + publicKey + signCount.
Use the clientDataJSON decoder and the attestation decoder to walk these structures byte-by-byte during integration.
Authentication ceremony
Server generates a fresh challenge, optionally lists allowCredentials (credentialIds it accepts for this user), sets userVerification policy.
Authenticator builds authenticatorData (rpIdHash, flags, signCount), signs authData || SHA-256(clientDataJSON), returns the signature plus the credentialId.
Lookup stored publicKey by credentialId, verify signature, check rpIdHash, verify UP / UV / counter monotonicity.
If everything checks, mint a session. Total round-trip on USB-HID is sub-second; NFC is sub-300ms.
Trust-zone diagram
authenticatorData — the load-bearing structure
authenticatorData is the binary the authenticator builds and signs over. The structure is fixed: rpIdHash(32) || flags(1) || signCount(4), optionally followed by attested-credential data (registration only) and CBOR extensions.
The flags byte is where most policy decisions live. UP (User Present) is the touch / tap. UV (User Verified) is local user verification — PIN, biometric, or pattern. BE (Backup Eligible) and BS (Backup State) tell you whether the credential is a syncable passkey. AT indicates attested-credential data is present (registration only). ED indicates extension data follows.
For high-assurance enterprise environments where you want a device-bound credential, require BE = 0. For consumer flows where convenience trumps assurance, accept BE = 1.
RP ID rules — §5.1.3
The RP ID is the scope of a credential. It must be a registrable domain suffix of, or equal to, the origin's effective domain. In practice this lets you set rpId = example.com on a page served from login.example.com — the credential becomes usable across all *.example.com hosts.
- Browsers reject public-suffix RP IDs (you can't register at
comorco.uk). - HTTPS is required — localhost is the only HTTP exception (development).
- Origin verification on the RP must be exact — case-insensitive host match, scheme + port comparison, no substring matching.
Use the RP ID validator to dry-run combinations before shipping.
Attestation, AAGUIDs, and the FIDO MDS
An authenticator can prove what it is via an attestation statement — signed by the manufacturer's root key. Each model has an AAGUID recorded in the FIDO Metadata Service (MDS) BLOB. Consumer apps usually skip attestation (attestationType: 'none'); enterprise apps that require certified hardware verify the attestation against MDS and enforce AAGUID allow-lists.
The MDS BLOB is signed by the FIDO Alliance and updated periodically; production RPs should fetch and verify it on a schedule, not at runtime.
For diagnostic work the AmbiSecure AAGUID lookup covers a curated subset of well-known devices.
Verification checklists
Registration verification (server-side)
- Decode
clientDataJSON; verifytype === "webauthn.create", challenge matches the one your RP issued (single-use), origin is in your allow-list. - Compute
SHA-256(rpId)and verifyauthData.rpIdHashmatches. - Verify
UPflag is set; verifyUVif your policy requires user verification. - If you require an attestation policy stronger than none, verify the attestation signature against
authData || SHA-256(clientDataJSON), and validate the x5c chain against your trust anchors and the FIDO MDS BLOB. - Enforce the AAGUID allow-list (enterprise).
- Persist
credentialId,credentialPublicKey,signCount, and the BE/BS flags for policy use.
Authentication verification (server-side)
- Decode
clientDataJSON; verifytype === "webauthn.get", challenge matches, origin matches. - Look up the stored credentialPublicKey by credentialId.
- Compute
SHA-256(rpId); verifyauthData.rpIdHash. - Verify
UPset;UVif required. - Verify the signature over
authData || SHA-256(clientDataJSON)using the stored public key. - Verify the new
signCount > stored signCount(or both 0 — many platform authenticators keep counter at 0). - Update stored
signCount; mint session.
Pillar reading
Understanding WebAuthn Attestation Objects
Walking the fmt / attStmt / authData CBOR map, byte by byte.
Read → Passkeys · PillarPasskeys vs Traditional MFA
What changes when the second factor moves from "something you know" to "something only your hardware can prove".
Read → Identity · PillarWhy Hardware-Backed Identity Matters
Where the trust really lives, and why software-only identity is structurally weaker.
Read →