Under the Hood of WebAuthn

Most WebAuthn explanations stop at the conceptual level: a keypair is created, the private key stays on the device, the server verifies a signature. That's correct, and it's roughly a tenth of what you need to implement a relying party without introducing a vulnerability.

Most WebAuthn explanations stop at the conceptual level: a keypair is created, the private key stays on the device, the server verifies a signature. That's correct, and it's roughly a tenth of what you need to implement a relying party without introducing a vulnerability.

This article is the other nine tenths — the actual bytes, the flag bits, the verification steps in the order they must happen, and the parameters whose defaults are wrong for what you're probably trying to build. If you're using a well-maintained library (you should be), treat this as the map of what that library is doing and where its configuration options bite.

Registration: what the browser hands you

You call navigator.credentials.create() with a challenge and some options. What comes back is an AuthenticatorAttestationResponse with two fields that matter: clientDataJSON and attestationObject.

clientDataJSON

A UTF-8 JSON string — not a struct, a string, and that distinction is load-bearing:

{
  "type": "webauthn.create",
  "challenge": "dGhpcy1pcy1hLXJhbmRvbS1jaGFsbGVuZ2U",
  "origin": "https://app.example.com",
  "crossOrigin": false
}

Four things to check, and one trap.

type must be exactly webauthn.create for registration and webauthn.get for authentication. Checking it prevents a ceremony confusion attack: a signature obtained during registration being replayed as an authentication, or vice versa. Libraries usually check this; hand-rolled code frequently doesn't.

challenge is the base64url encoding of the challenge you generated. It must match a challenge you issued, that you stored server-side, that hasn't been used, and that hasn't expired. The challenge must come from a CSPRNG and be at least 16 bytes — the spec's language is that it must be sufficiently random to make guessing infeasible, and 32 bytes is the sensible default.

origin must exactly match your expected origin, including scheme and port. Not "ends with your domain." Not "contains." Exact string comparison against an allowlist. Origin validation is the mechanism that makes WebAuthn phishing-resistant, and a substring match reintroduces the entire attack class.

crossOrigin should be false unless you deliberately support iframe usage.

The trap: the signature is computed over the SHA-256 of the raw bytes of clientDataJSON as delivered. Do not parse it, re-serialize it, and hash your version — key order, whitespace, and any future fields the client added would change the bytes and the signature would fail. Verify against the bytes you received, then parse those same bytes for the checks above. Client data may legitimately contain fields your code doesn't know about; that's by design, and it's why the spec talks about the "client data JSON" as an opaque byte string that you also happen to be able to read.

attestationObject

A CBOR map with three keys:

{
  "fmt": "packed",
  "attStmt": { "alg": -7, "sig": h'304502...', "x5c": [h'308201...'] },
  "authData": h'49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763450000000...'
}

fmt names the attestation statement format: packed, tpm, android-key, android-safetynet, apple, fido-u2f, or none. attStmt is the format-specific evidence. authData is the part you always need, regardless of whether you care about attestation.

authData, byte by byte

This is the structure worth memorizing, because it appears in both ceremonies and every important flag lives here.

Offset Length Field
0 32 rpIdHash — SHA-256 of the RP ID
32 1 flags
33 4 signCount — big-endian counter
37 16 AAGUID (registration only, if AT flag set)
53 2 credentialIdLength (big-endian)
55 L credentialId
55+L var credentialPublicKey (COSE_Key, CBOR)
… var extensions (if ED flag set)

The flags byte is where the semantics live:

Bit Name Meaning
0 UP User Presence — someone touched/tapped the authenticator
2 UV User Verified — biometric or PIN was checked
3 BE Backup Eligible — this credential can be synced
4 BS Backup State — this credential is currently backed up
6 AT Attested credential data included
7 ED Extension data included

Three notes that matter in practice.

UP versus UV is the distinction people get wrong. UP means a human interacted. UV means a human was identified — a fingerprint matched, a PIN was entered, a face was recognized. A credential used with UP but not UV is one factor: possession of the device. With UV, it's two: possession plus knowledge or biometric. If your policy requires MFA from a single passkey, you must require UV, and requiring it means both setting userVerification: "required" in the options and checking the UV bit on the server. The options field is a request to the client; the flag bit is the evidence. Trusting the request without checking the evidence is the most common WebAuthn implementation flaw I see, because it works perfectly in testing — the client honours the request — and it's bypassable by a client that doesn't.

BE and BS tell you whether the credential is synced. BE=0 means the credential is device-bound and cannot leave that authenticator — a hardware key, or a platform credential explicitly created as non-syncable. BE=1, BS=1 means it's in a cloud keychain and is therefore as available (and as recoverable) as that keychain account. This is exactly the information you need for a risk decision: a device-bound credential's loss is permanent but its theft requires physical possession; a synced credential survives device loss but inherits the security of an Apple or Google account. Record these flags at registration and re-check them on each authentication, because BS can change over time — a user enabling iCloud Keychain backup flips a previously-unbacked credential to backed-up.

signCount is mostly a historical artifact now. It exists to detect cloned authenticators: the counter should increase monotonically, so a lower-than-stored value implies a copy. Synced passkeys generally report 0 always, because a credential that legitimately exists on multiple devices cannot maintain a coherent counter. So: if the stored and received counts are both 0, skip the check. If the stored count is non-zero and the new one is not greater, that's a signal worth logging and possibly acting on. Never hard-fail on it for synced credentials, or you will lock out large populations of legitimate users.

credentialPublicKey, in COSE

The public key is a COSE_Key structure, CBOR-encoded. For an ES256 key:

{ 1: 2,        // kty: EC2
  3: -7,       // alg: ES256
 -1: 1,        // crv: P-256
 -2: h'...',   // x coordinate, 32 bytes
 -3: h'...' }  // y coordinate, 32 bytes

Store this. It's what you verify signatures against for the life of the credential. Two checks at registration: the alg must be one you requested in pubKeyCredParams, and the key type must be consistent with it — an authenticator returning an algorithm you didn't ask for should be rejected rather than accommodated.

On algorithm choice: request -7 (ES256) and -257 (RS256), in that order, since order expresses preference. ES256 is universally supported and cheaper to verify. -8 (EdDSA) is supported by some authenticators and is fine to include after those two. Do not include algorithms you can't verify, which sounds obvious and is a real source of "registration succeeded, authentication always fails."

Authentication: what changes

navigator.credentials.get() returns an AuthenticatorAssertionResponse: clientDataJSON (with type: "webauthn.get"), authenticatorData (the same structure, without the attested credential data — no AAGUID, no credential ID, no public key), signature, and userHandle.

The signature is computed over:

authenticatorData || SHA-256(clientDataJSON)

Concatenated raw bytes, in that order. Verify with the stored public key.

userHandle is the user.id you supplied at registration, returned by the authenticator for discoverable credentials. It's how usernameless login works: the user picks a credential, and the authenticator tells you which account it belongs to. Which means user.id must be an opaque, stable, non-PII byte string — a random 16–64 byte value or a UUID, never an email address or a sequential integer. It's stored on the authenticator, potentially visible in the credential picker's metadata, and it cannot be changed without re-registering the credential.

The verification steps, in order

Non-negotiable ordering, because several checks are only meaningful if earlier ones passed:

Registration:

  1. Challenge matches a stored, unused, unexpired challenge for this session.
  2. type is webauthn.create.
  3. origin is in your allowlist, exact match.
  4. rpIdHash equals SHA-256 of your RP ID.
  5. UP flag is set. If you require verification, UV flag is set.
  6. alg is one you requested.
  7. If you're checking attestation, verify attStmt per its fmt (see below).
  8. The credential ID is not already registered — to any account. A credential ID appearing under a second account is either an error or an attack.
  9. Store: credential ID, public key, signCount, AAGUID, BE/BS flags, and the transports the client reported.

Authentication:

  1. Look up the credential by ID. If allowCredentials was non-empty, confirm the returned credential was in that list.
  2. Confirm the credential belongs to the user you expect — or, for usernameless flows, resolve the user from userHandle and confirm the credential is registered to them.
  3. Challenge matches a stored, unused, unexpired challenge.
  4. type is webauthn.get.
  5. origin exact match.
  6. rpIdHash matches.
  7. UP set; UV set if required.
  8. Verify the signature over authenticatorData || SHA-256(clientDataJSON).
  9. signCount check, with the synced-credential caveat.
  10. Update stored signCount and BS flag.

Step 2 in the authentication list is the one that gets skipped in refactors, and skipping it is an authentication bypass: verifying that a valid credential signed the challenge, without confirming whose credential it was, lets any registered user authenticate as any other.

RP ID: the scoping rule with a sharp edge

The RP ID is the domain the credential is bound to. It defaults to the origin's effective domain, and you may set it to a registrable suffix of that domain — so a page on app.example.com may use an RP ID of app.example.com or example.com, but not com and not other.example.com.

The consequence people discover late: credentials are not usable across different RP IDs. Register with RP ID app.example.com and the credential is invisible on www.example.com. Register with example.com and it works across all subdomains.

So: choose the broadest RP ID you'll ever need, at the start. Migrating later means re-registering every credential, because there is no mechanism to re-scope an existing one. If your product might ever live on a second subdomain, use the apex domain.

For genuinely different domains — a .com and a .de for the same product, or a white-labelled domain per customer — the newer Related Origin Requests mechanism lets an RP publish a /.well-known/webauthn document listing origins permitted to use its RP ID. Support is still arriving across browsers, so treat it as an improvement rather than a foundation.

The options fields whose defaults you should override

residentKey / requireResidentKey / discoverability. For passkeys, set residentKey: "required" and requireResidentKey: true. A non-discoverable credential requires you to supply allowCredentials, which means you must know who the user is before they authenticate — no usernameless login, and no "sign in" button that just works. The legacy requireResidentKey boolean should be set consistently with residentKey for older authenticators.

userVerification. Default is "preferred", which means "ask if you can, proceed if you can't" — and produces credentials that sometimes provide two factors and sometimes one, with no way to tell from your policy. Choose "required" if you need MFA from the passkey alone, and then enforce the UV flag server-side. Choose "discouraged" only for genuine second-factor use where a password precedes it.

authenticatorAttachment. Leave unset unless you have a reason. Setting "platform" excludes hardware keys — which are the best backup credential available. Setting "cross-platform" excludes the convenient case. Unset means the user chooses, which is usually right.

excludeCredentials. Populate it at registration with the user's existing credential IDs. Otherwise a user registering a second passkey on the same authenticator creates a duplicate, and you accumulate credentials nobody can distinguish in a management UI.

timeout. 300000ms (5 minutes) is a reasonable value. The default is client-dependent, and a short timeout is hostile to anyone using a hardware key they need to go and find.

hints (newer) lets you nudge the browser's UI toward a security key, a client device, or a hybrid QR flow. Useful for enterprise flows where you know what you've issued.

Conditional mediation — mediation: "conditional" on get() — is what powers autofill-style passkey login: the browser offers credentials in the username field's dropdown without a modal. It requires discoverable credentials and it's the single biggest usability improvement available. Feature-detect with isConditionalMediationAvailable().

Error handling, and why it's so bad

The browser reports almost every failure as NotAllowedError. The user cancelled, the timeout elapsed, no matching credential was found, the authenticator refused — the same error for all of them, deliberately, so that a page can't distinguish "no credential for this account" from "user declined" and use it to enumerate accounts.

That's a good privacy property and it makes debugging miserable. Two mitigations: InvalidStateError is distinguishable and specifically means "this authenticator already has a credential excluded by excludeCredentials" — which you should surface as "you've already registered this device" rather than a generic failure. And for everything else, instrument the client side: log the option set you sent, the elapsed time before the error, and whether conditional mediation was in use. Elapsed time alone separates "user cancelled in two seconds" from "timeout after five minutes," which is most of what you need.

Attestation, briefly

Attestation is the authenticator proving its make and model, so you can enforce "only these approved hardware keys." Verifying it properly means parsing format-specific statements, validating certificate chains, and consulting the FIDO Metadata Service for root certificates and revocation.

For most deployments the correct setting is attestation: "none", and it deserves its own article's worth of argument. The short version: it adds a metadata-service dependency, a privacy consideration, and a support burden — users with unlisted authenticators simply cannot register — for a benefit only regulated environments genuinely need. If you're not in one of those environments and you can't name the specific authenticator models you're mandating and why, set it to none and check the flags and BE/BS bits instead, which give you most of the risk signal at none of the cost.

The five that actually cause vulnerabilities

Out of everything above, these are the ones that turn into findings:

  1. Origin checked by substring rather than exact match. Destroys phishing resistance.
  2. UV requested but not verified server-side. Your MFA claim is unfounded.
  3. Challenge not stored server-side, or reusable. Replay.
  4. Credential ownership not confirmed at authentication. Any user can authenticate as any user.
  5. clientDataJSON re-serialized before hashing. Not a vulnerability, but it's the most common reason a correct implementation fails to verify, and it drives people to disable checks until it works.

Everything else on this list is correctness and user experience. Those five are the security boundary.