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 clientDataJSON and 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.

WebAuthn / FIDO2 Protocol Stack Three-layer diagram showing the Relying Party at top, Browser/Client in the middle, and Authenticator at the bottom, connected by the WebAuthn API and CTAP2 protocol respectively. Relying Party (RP) Stores public key + credentialId · validates signatures W3C WebAuthn §7 — server verification algorithm WebAuthn API Browser / OS Client Enforces origin binding · constructs clientDataJSON Chromium · WebKit · Gecko — conditional mediation support varies CTAP2 / HID / BLE / NFC Authenticator (Platform or Roaming) Generates key pair · signs challenges · private key never exported Secure Enclave · TPM · Android Keystore · FIDO2 Security Key
WebAuthn / FIDO2 three-layer trust model. The RP and authenticator never communicate directly; the client layer enforces origin binding between them.

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

  1. 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.

  2. Mismatching rp.id across environments. Root cause: staging uses auth.staging.example.com while production uses example.com, but both share a credential database. Mitigation: derive rpId from the request Origin header server-side; never hard-code it.

  3. Ignoring the UV flag. Root cause: setting userVerification: 'required' in options but not checking the UV bit in authenticatorData. Mitigation: parse flags byte and reject responses where UV is unset if your flow demands user verification.

  4. Passing COSE label to createVerify(). Root cause: using 'ES256' as the algorithm argument — Node.js crypto expects OpenSSL digest names such as 'SHA256' or 'RSA-SHA256'. Mitigation: maintain a COSE-to-OpenSSL algorithm map in your verification layer.

  5. Hard-coding ES256 only. Root cause: omitting RS256 (-257) from pubKeyCredParams. Legacy enterprise hardware keys (FIPS-certified HSMs, YubiKey 4 series) only support RSA. Mitigation: always include RS256 as a fallback in the preference array.

  6. 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.

  7. Storing attestation certificates. Root cause: persisting the full attestationObject for audit purposes. Mitigation: parse, verify, extract AAGUID and public key, then discard the raw attestation object. Store a hash if audit trail is needed.

  8. Skipping signCount validation. Root cause: not updating or checking the counter on assertion. Mitigation: compare incoming signCount to 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