How WebAuthn Actually Works

WebAuthn is the browser API component of the FIDO2 standard. FIDO2 defines two layers: the Client to Authenticator Protocol (CTAP) for communication between browser and authenticator (hardware key, platform authenticator like Touch ID, etc.), and the Web Authentication API (WebAuthn) for communication between website and browser.

Registration creates a credential. The authenticator generates a new asymmetric key pair scoped to the relying party identifier (typically the registering domain). The private key never leaves the authenticator. The public key and a credential ID are returned to the server and stored. Crucially, the authenticator signs the attestation statement with its device attestation key, allowing the server to verify the authenticator model and manufacturer.

Authentication produces an assertion. The server sends a challenge (random bytes). The authenticator looks up the private key for this relying party, signs the challenge along with the authenticator data (which includes the origin hash), and returns the signature. The server verifies the signature with the stored public key and checks that the signed origin matches its own.

The phishing resistance property derives from this origin binding. When a victim visits a phishing site at login-example-corn.com, the browser contacts the authenticator with that site's origin in the client data. The authenticator generates an assertion scoped to login-example-corn.com. The legitimate server at example.com verifies the origin in the signed client data and rejects the assertion β€” the phishing site cannot replay it because the signature covers the wrong origin.

The Cryptography

WebAuthn uses CBOR (Concise Binary Object Representation) for encoding authenticator data and COSE (CBOR Object Signing and Encryption) for representing public keys and algorithm identifiers. Supported algorithms are identified by COSE algorithm numbers: -7 for ES256 (ECDSA with P-256 and SHA-256), -257 for RS256 (RSASSA-PKCS1-v1_5 with SHA-256), -8 for EdDSA.

When choosing which algorithms to accept during registration, prefer ES256 or EdDSA over RS256. RS256 requires RSA key sizes above 2048 bits to be safe; ES256 provides equivalent security with much shorter keys and is hardware-friendly. Accepting RS256 with a server-side minimum key size check is complex and commonly misconfigured.

Algorithm confusion is real: Accept only the algorithms you explicitly list in pubKeyCredParams during registration. Verify that the algorithm in the credential's COSE key structure matches what you registered. Do not accept an assertion signed with RS256 if you registered ES256 β€” the credential public key contains the algorithm identifier and you must check it on every verification.

Attestation and Trust

Attestation lets the server verify which authenticator model and manufacturer produced a credential. This matters for high-security use cases: an enterprise might require that all credentials come from specific hardware security key models, not from software authenticators on personal devices.

Attestation formats include packed (the most common, used by most platform authenticators and hardware keys), FIDO U2F (legacy hardware keys), Android Key Attestation (Android devices), and Apple Anonymous Attestation (Apple platform authenticators).

For most consumer applications, requesting attestation: "none" during registration is correct β€” you don't need to know which device type created the credential, and requesting attestation complicates the implementation without security benefit when you would accept any conformant authenticator anyway. The complexity of attestation verification introduces more attack surface than it eliminates unless you have a genuine requirement to restrict authenticator types.

Implementation Pitfalls

Challenge Verification

The challenge sent during authentication must be a cryptographically random value generated server-side, stored (briefly) server-side, and verified server-side that the signed client data contains exactly that challenge. Challenges must be single-use and expire quickly (30-60 seconds). Reusing challenges, using predictable challenges, or failing to store them server-side allows replay attacks.

Credential ID Binding to User

The credential ID returned during authentication must be looked up in your user database and must belong to the authenticated user account. A credential substitution attack presents a valid credential assertion for a different user's credential β€” if you look up the credential globally without checking the user association, a user can authenticate as any account whose credential ID they know.

Signature Counter

Hardware authenticators maintain a monotonically increasing signature counter. The server should track the last-seen counter and reject assertions where the counter has not increased, or where the counter is zero (indicating a cloned authenticator). Platform authenticators (like Apple's Secure Enclave implementation) may return counter 0 legitimately because their private key isolation makes counter verification redundant β€” handle this case correctly.

// Server-side verification checklist (pseudocode) verifyAssertion(credential, storedCredential, challenge) { assertAlgorithm(credential.key.alg === storedCredential.registeredAlg) assertOrigin(clientData.origin === "https://example.com") assertRpId(sha256(authData.rpIdHash) === sha256("example.com")) assertChallenge(clientData.challenge === challenge) assertChallengeSingleUse(deleteChallenge(challenge)) assertSignatureValid(credential.sig, storedCredential.pubKey) assertCounterIncreased(authData.signCount > storedCredential.signCount) assertCredentialBelongsToUser(credential.id, authenticatedUserId) }

Account Recovery: Where Phishing Resistance Goes to Die

This is the elephant in the room. WebAuthn provides strong phishing resistance β€” but only for the WebAuthn authentication path. Account recovery flows that fall back to email, SMS, or security questions reintroduce all the phishing and SIM-swap vulnerabilities that passkeys were supposed to eliminate.

An attacker targeting a passkey-protected account doesn't attack the WebAuthn flow. They attack the recovery flow: "I've lost access to my device, please reset my account." If that recovery flow sends a code to an email address or phone number, the attacker targets the email or the carrier β€” the same attacks that have always worked.

The only recovery mechanisms that maintain the phishing-resistance property are: additional registered passkeys (e.g., one on your laptop, one on your phone), hardware security keys stored separately, or recovery codes stored offline. SMS and email recovery maintain convenience at the cost of the security guarantee.

The strongest WebAuthn deployment registers multiple credentials per account (multiple device authenticators), enforces a minimum of two before disabling recovery flows, and provides offline recovery codes as the last resort β€” not SMS or email links.

Server-Side Verification Libraries

Do not implement WebAuthn verification from scratch. The protocol has enough corner cases β€” CBOR parsing, COSE key format, attestation chain verification, counter handling β€” that hand-rolled implementations consistently have bugs. Use a well-audited library:

  • Python: py_webauthn by Duo Security β€” actively maintained, used in production at scale
  • Node.js: @simplewebauthn/server β€” comprehensive, TypeScript-typed, extensively tested
  • Go: github.com/go-webauthn/webauthn β€” the standard Go implementation
  • Java: com.yubico:webauthn-server-core by Yubico β€” authoritative Java implementation from a FIDO key manufacturer

Test with known-bad assertions: Your verification stack should reject: replayed challenges, wrong origin, wrong rpId, mismatched algorithm, invalid signatures, and counter regressions. Test each rejection case explicitly β€” a library that does not throw on a wrong rpId is broken, not lenient.