WebAuthn & FIDO2 Protocol Fundamentals
The Web Authentication (WebAuthn) API and FIDO2 protocol stack are the industry standard for phishing-resistant, passwordless authentication. By replacing shared secrets with asymmetric public-key cryptography and hardware-backed credential storage, this architecture shifts identity verification from knowledge-based factors to possession-based proofs. This reference covers the protocol specifications, cryptographic foundations, registration and authentication workflows, cross-platform behaviour, and compliance boundaries that full-stack developers, security engineers, and identity platform builders need for production-grade passkey deployments.
Protocol Hierarchy and Ecosystem Architecture
WebAuthn is a W3C Recommendation (Level 2 finalized, Level 3 in CR) that standardizes browser-level APIs for public-key credential management. It operates alongside the FIDO Alliance’s Client to Authenticator Protocol (CTAP2), which governs the transport-layer conversation between the client platform and external or platform authenticators. Together they eliminate server-side password storage, neutralize credential stuffing, and cryptographically bind every authentication attempt to the requesting origin — making phishing and relay attacks cryptographically infeasible. For a detailed breakdown of where the W3C and FIDO Alliance specifications diverge, see Understanding WebAuthn vs FIDO2 Architecture.
The trust model is strictly partitioned into three non-overlapping layers:
- Client layer. The browser or OS acts as the communication bridge, enforcing origin validation and mediating user consent. The client constructs
clientDataJSONand forwards it to the authenticator. - Authenticator layer. Hardware Security Modules (HSMs), Secure Enclaves, or TPMs generate and store private keys. Private key material never crosses the authenticator boundary under any code path.
- Relying Party (RP) layer. The application server stores only the COSE-encoded public key and a
credentialId. It holds zero knowledge of the corresponding private signing material.
FIDO U2F required physical USB/NFC tokens as a second factor. FIDO2 extended this by introducing CTAP2 alongside WebAuthn, enabling platform authenticators (biometrics, Windows Hello, Touch ID) and discoverable credentials (passkeys) that synchronize across ecosystems via encrypted cloud vaults.
Core Concepts
Relying Party Identity and Origin Binding
The RP ID is a registrable domain suffix of the page origin (§ 5.4.2 of the WebAuthn Level 2 spec). During both registration and authentication the browser compares the effective domain of window.location against rpId and rejects any mismatch with a SecurityError before the authenticator is even contacted. See Relying Party and Authenticator Roles for the full scope of duties each party owns.
// Server-side: derive and validate the RP ID from the request origin
function deriveRpId(origin: string): string {
const url = new URL(origin);
// Strip www. — rpId must be an eTLD+1 or explicit subdomain
return url.hostname.replace(/^www\./, '');
}
function validateOrigin(origin: string, expectedRpId: string): boolean {
if (!origin) return false;
const { hostname } = new URL(origin);
return hostname === expectedRpId || hostname.endsWith(`.${expectedRpId}`);
}
Discoverable Credentials (Resident Keys) vs Server-Side Credentials
Credentials are classified along a single axis: whether the credential record is stored entirely on the authenticator (discoverable) or only the private key is stored there while the RP retains the credentialId mapping (server-side). Discoverable credentials are required for username-less flows and are the foundation of the passkey sync model. For trade-off analysis covering storage cost, roaming key compatibility, and CTAP2 credProtect extensions, see Public Key vs Symmetric Credential Types.
// Registration options: prefer discoverable credential for passkey sync
const creationOptions: PublicKeyCredentialCreationOptionsJSON = {
authenticatorSelection: {
residentKey: 'preferred', // 'required' for guaranteed discoverable storage
userVerification: 'required', // UV flag must be set in authenticatorData
authenticatorAttachment: 'platform', // 'cross-platform' for roaming security keys
},
};
COSE Algorithm Selection
During registration the pubKeyCredParams array (§ 5.4 of the spec) declares the RP’s algorithm preferences in priority order. The authenticator selects the first algorithm from the list that it supports. The algorithm identifier is a COSE algorithm ID as defined in RFC 8152 / IANA COSE Algorithms registry. For the full algorithm compatibility matrix and hardware support table, see Cryptographic Algorithms Supported by WebAuthn.
// COSE algorithm preference array — order matters: most preferred first
const pubKeyCredParams: PublicKeyCredentialParameters[] = [
{ type: 'public-key', alg: -7 }, // ES256 — ECDSA P-256 + SHA-256 (universal support)
{ type: 'public-key', alg: -8 }, // EdDSA — Ed25519 (modern platform authenticators)
{ type: 'public-key', alg: -257 }, // RS256 — RSA PKCS#1 v1.5 + SHA-256 (legacy enterprise keys)
];
The Challenge-Response Model
The RP generates a cryptographically random nonce (challenge), stores it server-side bound to a TTL, and delivers it inside PublicKeyCredentialCreationOptions or PublicKeyCredentialRequestOptions. The authenticator signs authenticatorData || SHA-256(clientDataJSON) using the private key and returns the signature. The server then verifies the signature against the stored public key. Every challenge is single-use; consuming it on arrival prevents replay attacks. See The Challenge-Response Authentication Flow for the full step-by-step verification sequence.
import { randomBytes } from 'crypto';
function generateChallenge(): Buffer {
return randomBytes(32); // 256-bit entropy — §13.1 of the WebAuthn spec
}
// Node.js ECDSA signature verification
import { createVerify, createHash } from 'crypto';
function verifyECDSASignature(
publicKeySpki: Buffer,
signature: Buffer,
authenticatorData: Buffer,
clientDataJSON: Buffer
): boolean {
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
const signedData = Buffer.concat([authenticatorData, clientDataHash]);
const verifier = createVerify('SHA256'); // OpenSSL digest name, not COSE label
verifier.update(signedData);
return verifier.verify({ key: publicKeySpki, dsaEncoding: 'der' }, signature);
}
Attestation and the FIDO Metadata Service
Attestation vs Assertion Explained defines the distinction in depth, but the key architecture point is this: attestation (produced by navigator.credentials.create()) provides cryptographic proof of the authenticator’s hardware origin; assertion (produced by navigator.credentials.get()) proves user possession of the private key at authentication time. Attestation formats include packed, tpm, android-key, apple, fido-u2f, self, and none. The FIDO Metadata Service (MDS3) provides the trust anchors — AAGUID-to-certificate mappings — needed to validate packed and tpm statements.
// Attestation conveyance preference — for consumer apps default to 'none' or 'indirect'
const creationOptions = {
attestation: 'indirect' as AttestationConveyancePreference,
// 'direct' — full certificate chain returned; required for FIDO Certified hardware scoring
// 'enterprise' — internal PKI attestation for managed corporate devices
// 'none' — privacy-preserving; acceptable for most consumer and B2B deployments
};
The authenticatorData Structure
authenticatorData is a 37-byte-minimum binary structure (§ 6.1 of the spec) that the authenticator appends to every response. It encodes the RP ID hash, flags byte, signature counter (signCount), AAGUID (during registration), and optional extensions. The flags byte carries critical per-operation bits: UP (user presence), UV (user verification), BE (backup eligible), BS (backup state). Servers must check the UV flag when userVerification: 'required' is set in request options.
// Parsing the authenticatorData flags byte
function parseAuthDataFlags(authData: Buffer) {
const flagsByte = authData[32]; // byte index 32 in the 37-byte minimum structure
return {
UP: !!(flagsByte & 0x01), // user presence
UV: !!(flagsByte & 0x04), // user verification
BE: !!(flagsByte & 0x08), // backup eligible (sync-capable credential)
BS: !!(flagsByte & 0x10), // backup state (currently synced)
AT: !!(flagsByte & 0x40), // attested credential data present
ED: !!(flagsByte & 0x80), // extension data present
};
}
Credential Storage Schema
The RP’s credential table maps user_id to credential_id (BYTEA) plus the COSE-encoded public_key (BYTEA), COSE alg integer, sign_count, AAGUID, and transports. The sign_count column enables cloning detection: if the incoming signCount is not greater than the stored value (for non-zero counters), the authenticator may be cloned. For indexing strategies and multi-tenant schema design, see Backend Verification and Secure Credential Storage.
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA UNIQUE NOT NULL,
public_key BYTEA NOT NULL, -- raw COSE-encoded; never store as TEXT
alg INTEGER NOT NULL, -- COSE algorithm ID: -7 / -8 / -257
sign_count INTEGER NOT NULL DEFAULT 0,
aaguid UUID, -- from attestedCredentialData; index for MDS3 lookups
transports TEXT[], -- 'internal' | 'usb' | 'nfc' | 'ble' | 'hybrid'
be_flag BOOLEAN NOT NULL DEFAULT FALSE, -- backup eligible
bs_flag BOOLEAN NOT NULL DEFAULT FALSE, -- backup state
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- O(1) authentication lookups
CREATE UNIQUE INDEX idx_credentials_credential_id ON webauthn_credentials(credential_id);
-- Device management queries
CREATE INDEX idx_credentials_user_id_aaguid ON webauthn_credentials(user_id, aaguid);
Validation and Security Boundaries
Server-Side Validation Checklist
Every WebAuthn assertion response must pass all of the following checks before issuing a session token:
DOMException Error Mapping
| Exception | HTTP analogue | Trigger condition | Diagnostic action |
|---|---|---|---|
NotAllowedError |
403 | User denied, timeout, or gesture missing | Prompt retry; check browser biometric permissions |
SecurityError |
400 | rpId mismatch, insecure context, cross-origin iframe without allow="publickey-credentials-get" |
Enforce HTTPS; verify rp.id derivation logic |
NotSupportedError |
422 | Unsupported algorithm or authenticator type | Widen pubKeyCredParams; fall back to legacy auth |
InvalidStateError |
409 | Credential already registered on this authenticator | Redirect to sign-in flow |
AbortError |
499 | AbortController signal fired |
Clean up pending UI state; allow retry |
UnknownError |
500 | Authenticator internal failure (TPM error, key store full) | Log AAGUID + transport; surface user-friendly message |
Feature Detection
async function assessWebAuthnSupport() {
if (
typeof window === 'undefined' ||
!window.PublicKeyCredential
) return { supported: false };
const [platformAvailable, conditionalAvailable] = await Promise.all([
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false),
]);
return { supported: true, platformAvailable, conditionalAvailable };
}
Cross-Platform and Cross-Device Considerations
Platform behaviour diverges across ecosystems in ways that affect authenticatorData content, sync semantics, and conditional mediation support:
| Platform | Authenticator type | Discoverable credential | Conditional mediation | BE/BS flags |
|---|---|---|---|---|
| iOS / iPadOS Safari ≥ 16 | Secure Enclave (platform) | Synced via iCloud Keychain | Supported (iOS 16+) | BE=1, BS=1 when iCloud sync active |
| Android Chrome ≥ 108 | Titan chip / StrongBox (platform) | Synced via Google Password Manager | Supported (Android 9+ with API level 28) | BE=1, BS=1 when GPM sync active |
| Windows Hello (Edge / Chrome) | TPM 2.0 or software fallback | Yes, device-bound only (no cross-device sync for platform creds) | Supported | BE=0 when device-bound |
| Cross-platform roaming keys (YubiKey, etc.) | External FIDO2 security key | Yes (if CTAP2.1 with credProtect) |
Not applicable | BE=0, BS=0 (no sync) |
Cross-device QR hybrid transport. When a user selects “Use a passkey from another device”, the client initiates a CTAP2 hybrid transport flow over Bluetooth Low Energy. The ceremony is proxied via a QR-code handshake establishing an encrypted channel. Latency spikes are expected; implement UI timeouts of at least 90 seconds and surface a “Did not work?” fallback.
Conditional mediation (mediation: 'conditional'). This enables the browser to surface passkeys in the autofill UI without an explicit user gesture. The navigator.credentials.get() call must be made before the user focuses the username input, and autocomplete="username webauthn" must be set on the relevant input element. Browsers that do not support conditional mediation will ignore the call silently — always fall through to a traditional credential picker.
Sync propagation latency. iCloud Keychain and Google Password Manager sync is asynchronous. A credential created on device A may not be visible on device B for seconds to minutes. Design fallback authentication paths (recovery codes, account email OTP) for first-login scenarios on new devices.
Compliance Mapping
| Pillar concept | NIST SP 800-63B | FIDO2 Security Requirements | PSD2 SCA | GDPR |
|---|---|---|---|---|
User verification (userVerification: 'required' + UV flag) |
AAL2 (biometric or PIN); AAL3 requires hardware key | FIDO2 Certified Level 1+ for AAL2 | Inherence + possession factor combination satisfies SCA | Not applicable |
Attestation (direct or enterprise) |
AAL3 hardware-bound authenticator evidence | FIDO2 Certified Level 2 (hardware security key) | Satisfies “possession of a payment instrument” with hardware proof | Attestation certs may contain device identifiers — minimize retention |
signCount cloning detection |
Recommended for all assurance levels | Required for FIDO2 Certified implementations | Not specified | Not applicable |
| Discoverable credentials (passkeys) | AAL1–AAL2 depending on UV enforcement | Requires CTAP2 with rk capability |
Acceptable as possession factor when UV enforced | Passkey sync stores public material in cloud — data-processor agreement with Apple/Google required |
| AAGUID-to-MDS3 metadata lookup | Recommended for AAL3 | Required for Authenticator Certification claims | Not specified | AAGUID is not personal data; safe to store without consent requirement |
GDPR data minimisation: Use attestation: 'none' or attestation: 'indirect' for consumer deployments. If you process direct attestation for hardware-trust scoring, parse and discard the certificate chain after verification — do not persist attestation certificates alongside credential records.
Common Pitfalls
-
Reusing challenges. Root cause: generating the challenge once per session rather than per operation. Mitigation: store challenges in Redis with
SET NX EX 120; delete the key immediately on first consumption. -
Mismatching
rp.idacross environments. Root cause: staging usesauth.staging.example.comwhile production usesexample.com, but both share a credential database. Mitigation: deriverpIdfrom the requestOriginheader server-side; never hard-code it. -
Ignoring the UV flag. Root cause: setting
userVerification: 'required'in options but not checking the UV bit inauthenticatorData. Mitigation: parse flags byte and reject responses where UV is unset if your flow demands user verification. -
Passing COSE label to
createVerify(). Root cause: using'ES256'as the algorithm argument — Node.jscryptoexpects OpenSSL digest names such as'SHA256'or'RSA-SHA256'. Mitigation: maintain a COSE-to-OpenSSL algorithm map in your verification layer. -
Hard-coding
ES256only. Root cause: omitting RS256 (-257) frompubKeyCredParams. Legacy enterprise hardware keys (FIPS-certified HSMs, YubiKey 4 series) only support RSA. Mitigation: always include RS256 as a fallback in the preference array. -
Assuming sync is instantaneous. Root cause: expecting a passkey created on one device to be immediately available on another. Mitigation: implement grace-period polling on the client and always provide a non-passkey recovery path.
-
Storing attestation certificates. Root cause: persisting the full
attestationObjectfor audit purposes. Mitigation: parse, verify, extract AAGUID and public key, then discard the raw attestation object. Store a hash if audit trail is needed. -
Skipping
signCountvalidation. Root cause: not updating or checking the counter on assertion. Mitigation: compare incomingsignCountto stored value; if the stored counter is non-zero and the new value is not greater, flag the credential for review and notify the user.
Related
- Understanding WebAuthn vs FIDO2 Architecture — W3C spec vs FIDO Alliance CTAP2 boundary, transport layer details
- Relying Party and Authenticator Roles — duty separation, RP ID derivation, authenticatorData ownership
- The Challenge-Response Authentication Flow — step-by-step registration and assertion verification sequence
- Attestation vs Assertion Explained — attestation formats, AAGUID resolution, MDS3 trust anchors
- Cryptographic Algorithms Supported by WebAuthn — ES256, EdDSA, RS256 compatibility matrix and COSE key map parsing
- Public Key vs Symmetric Credential Types — discoverable vs server-side credential trade-offs, resident key storage limits
- Platform vs Roaming Authenticator Trade-offs —
authenticatorAttachment, AAGUID provenance, and sync vs device-bound assurance - Discoverable vs Server-Side Credentials —
residentKeypolicy, usernameless autofill, andcredProps.rk - Debugging WebAuthn Protocol Errors — decoding
SecurityError,InvalidStateError, and authenticatorData flag mismatches - Backend Verification and Secure Credential Storage — server-side verification algorithms, credential schema design, signCount management
- Frontend UX and Conditional Mediation — the client-side ceremony, conditional mediation autofill, and progressive enhancement