privileged.id

Authentication

WebAuthn

The browser API behind security keys and passkeys. The two ceremonies step by step, the data your server receives, and the checks it must make.

Reviewed 7 min read 3 primary sources

Web Authentication (WebAuthn) is the W3C API that lets a web page create and use public-key credentials held by an authenticator: a security key, a phone, or a platform authenticator built into the computer. Level 3 became a W3C Recommendation on 25 August 2026. Level 2 has been a Recommendation since 8 April 2021. This page walks through the two ceremonies, the data the relying party receives, the checks it must perform, and how WebAuthn relates to CTAP and FIDO2. Section references are to WebAuthn Level 3.

Parties and the FIDO2 relationship

Relying party (RP)
The website or service. Its JavaScript calls the API, and its server generates challenges and verifies responses.
Client
The browser or operating system platform that implements the API, enforces origin rules and talks to authenticators.
Authenticator
Creates and stores credential key pairs, tests user presence and optionally performs user verification, and signs. Platform authenticators are built into the device. Roaming authenticators connect over USB, NFC, Bluetooth or the hybrid (phone-as-authenticator) transport.

FIDO2 is the umbrella for two specifications: W3C WebAuthn (browser to relying party) and the FIDO Alliance's Client to Authenticator Protocol, CTAP (client to external authenticator). WebAuthn calls CTAP "an example of a concrete instantiation" of its authenticator model. CTAP2 messages are CBOR maps with integer keys, which the client translates into the string-keyed structures WebAuthn defines. A relying party never speaks CTAP. It only sees WebAuthn objects.

The two ceremonies

WebAuthn registration and authentication ceremonies between authenticator, client and relying party server. Authenticator Client (browser) RP server Registration: credentials.create() 1. challenge, rp, user, algs 2. check RP ID vs origin, build clientDataJSON 3. clientDataHash, RP ID 4. user gesture, new key pair 5. attestationObject 6. clientDataJSON + attObj 7. verify (§7.1), store public key Authentication: credentials.get() 1. challenge, rpId, allowCreds 2. clientDataHash, RP ID 3. user gesture, sign with key 4. authData, signature 5. + clientDataJSON, userHandle 6. verify (§7.2), update signCount
The server's random challenge goes in, and the browser adds the origin. The authenticator signs over its own data plus a hash of the client data. The server checks every part.

Registration

  1. The server generates a random challenge and sends creation options: the RP (rp.id, name), the user (user.id, a user handle that MUST NOT contain personally identifying information such as a username or email address), acceptable algorithms (pubKeyCredParams), and preferences such as authenticatorSelection and attestation.
  2. The browser checks that the requested RP ID is the page's effective domain or a registrable suffix of it, and builds clientDataJSON containing the type webauthn.create, the challenge and the page's origin.
  3. The authenticator obtains a user gesture (presence, and verification if requested), generates a new key pair scoped to the RP ID, and returns an attestation object: authenticator data including the new credential ID and public key, plus an optional attestation statement.
  4. The server verifies the response (section 7.1) and stores a credential record: credential ID, public key, sign count, UV, BE and BS values, and transports.

Authentication

  1. The server sends a new challenge, the rpId, and either a list of allowed credential IDs or an empty list (for discoverable credentials).
  2. The browser builds clientDataJSON with type webauthn.get.
  3. The authenticator finds a credential for that RP ID, obtains the gesture, and signs the concatenation of its authenticator data and the SHA-256 hash of clientDataJSON.
  4. The server verifies the assertion (section 7.2) and updates the stored sign count and backup state.

RP ID and origin: where phishing resistance comes from

The RP ID is a domain. By default it is the origin's effective domain. A page may set it to a registrable domain suffix of its own domain (for example, login.example.com may use example.com), but not to an unrelated domain. The one exception is Level 3's opt-in related origin requests (section 5.11): a client that supports them accepts another RP ID only if that domain lists the calling origin at https://<RP ID>/.well-known/webauthn. Credentials are bound to the RP ID for life, and the authenticator stores the SHA-256 hash of the RP ID in every response as rpIdHash.

The origin is checked separately. Section 13.4.9 says the relying party "MUST validate the origin member of the client data" and "MUST NOT accept unexpected values of origin". The section explains why this is not redundant: it is an extra layer in case a faulty authenticator fails to enforce credential scope. Together, these two checks are what SP 800-63B-4 calls verifier name binding. See phishing-resistant MFA.

clientDataJSON

The client data is JSON produced by the browser, not by your JavaScript. A registration example:

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

Defined members: type (prevents swapping a registration signature for an authentication one), challenge (base64url of the server's challenge), origin, and optionally crossOrigin and topOrigin for use inside cross-origin iframes. Level 3 reserves the old tokenBinding member and does not expect it to be used. Parse it as JSON and tolerate unknown keys and reordering. The spec warns that the structure may be extended.

Challenges, per section 13.4.3, MUST be randomly generated by the relying party in a trusted environment, such as the server. The returned value MUST match, and the RP SHOULD store the challenge until the operation completes rather than trusting the client to echo it back.

Authenticator data and flags

Authenticator data is a byte array of at least 37 bytes (section 6.1):

FieldBytesContents
rpIdHash32SHA-256 of the RP ID
flags1Bit flags, below
signCount4Signature counter, 32-bit unsigned big-endian
attestedCredentialDatavariablePresent when AT is set (registration): AAGUID, credential ID, public key
extensionsvariableCBOR map, present when ED is set
BitFlagMeaning
0UP, User PresentThe authenticator performed a test of user presence (for example, a touch).
1RFU1Reserved, zero.
2UV, User VerifiedThe authenticator verified the user (PIN, biometric).
3BE, Backup EligibilityThe credential is a multi-device credential. Set at creation and MUST NOT change.
4BS, Backup StateThe credential is currently backed up. Can change. BS = 1 with BE = 0 is not allowed.
5RFU2Reserved, zero.
6ATAttested credential data included (set for registration, never for assertions).
7EDExtension data included.

UV matters for assurance. SP 800-63B-4 section 3.1.7.2 says that if a multi-factor authenticator's flag shows the activation factor was not used, "the authentication SHALL be treated as single-factor".

Signature counter

Authenticators SHOULD implement a signature counter, ideally per credential. Those that do not leave it at zero. During authentication, if either the stored or the received value is non-zero and the received value is not greater than the stored one, section 7.2 calls this "a signal, but not proof" of a cloned authenticator. It could also be a malfunction or a race between parallel requests. What to do (fail, flag or accept) is the relying party's decision. If both values are zero, the check is skipped entirely. That is how the algorithm accommodates authenticators without a counter.

Attestation

Attestation lets an authenticator prove what kind of authenticator created a credential. The RP states its preference with the attestation option (section 5.4.7):

  • none (default): the RP does not want attestation. The client replaces any non-self attestation with a "none" statement.
  • indirect: the RP wants a verifiable statement but lets the client choose how to obtain it, for example through an anonymization CA.
  • direct: the RP wants the statement as generated by the authenticator.
  • enterprise: the RP wants an attestation that may uniquely identify the authenticator. User agents MUST NOT provide it unless configuration permits it for that RP ID. It is intended for controlled enterprise deployments.

Level 3 defines these attestation statement formats: packed, tpm, android-key, android-safetynet, fido-u2f, none, apple (Apple Anonymous) and compound. Attestation types are Basic, Self, AttCA, AnonCA and None. To verify a statement, the RP needs trust anchors for the format and type. The spec gives the FIDO Metadata Service, keyed by AAGUID, as one source. If attestation is not trustworthy, the RP SHOULD fail registration, or MAY register the credential and treat it as self-attested if its policy allows.

Server-side verification checklist

A summary of sections 7.1 and 7.2. Use a maintained library rather than writing these by hand, and use this list to check that the library is configured correctly.

Registration (§7.1)

  • C.type is webauthn.create, and C.challenge equals the challenge you issued for this session.
  • C.origin is one of your expected origins. crossOrigin and topOrigin are only accepted where you expect embedding.
  • rpIdHash equals SHA-256 of your RP ID.
  • UP is set (unless conditional mediation was used). UV is set if you require user verification.
  • If BE is not set, BS is not set. Evaluate BE and BS if your policy uses them.
  • The credential public key's alg is one you offered in pubKeyCredParams.
  • The attestation statement verifies under its format's procedure, and its trust path is acceptable under your policy.
  • The credential ID is at most 1023 bytes and not already registered to any user.
  • Store the credential record: ID, public key, signCount, UV, BE, BS, transports.

Authentication (§7.2)

  • If you sent allowCredentials, the credential ID is one of them.
  • The credential belongs to the identified user. For discoverable flows, userHandle is present and maps to an account that owns this credential ID.
  • C.type is webauthn.get, the challenge matches, and the origin is expected.
  • rpIdHash matches your RP ID. UP is set. UV is set if required.
  • BE has not changed from the stored value. BS is not set when BE is not set.
  • The signature verifies over authData || SHA-256(clientDataJSON) with the stored public key.
  • Apply your signature-counter policy, then update the stored signCount, BS and UV values.

Common mistakes

  • Generating challenges in browser JavaScript, or accepting any challenge the client returns.
  • Checking the origin with a substring or "ends with" match that also accepts evil-example.com.
  • Ignoring UV and then counting the result as multi-factor.
  • Requiring direct attestation for a consumer service, which mostly blocks users and pushes them to weaker methods.
  • Letting a user register a credential without a recent strong sign-in, which lets an attacker with a stolen session add their own authenticator.
  • Offering only one credential per account with no recovery path.

Sources

  1. Web Authentication: An API for accessing Public Key Credentials, Level 3 (W3C Recommendation, 25 August 2026), W3C. W3C Document License (cited, brief quotations).
  2. NIST SP 800-63B-4: Authentication and Authenticator Management, NIST. Public domain (U.S. government work).
  3. CISA fact sheet: Implementing Phishing-Resistant MFA (October 2022), CISA. Public domain (U.S. government work).