Understanding WebAuthn vs FIDO2 Architecture

Engineering teams deploying phishing-resistant authentication must understand the architectural boundary between the broader FIDO2 ecosystem and the W3C WebAuthn specification before a single line of registration code is written. This page dissects the protocol stack layer by layer — CTAP2, WebAuthn, and the FIDO Alliance Metadata Service (MDS3) — mapping each to its trust boundary and implementation obligation. For the overall protocol context this topic fits into, see WebAuthn & FIDO2 Protocol Fundamentals.


Concept Definition and Spec Grounding

FIDO2 is not a single specification. It is a composite standard maintained by the FIDO Alliance comprising two tightly coupled sub-specs:

  • Client to Authenticator Protocol 2 (CTAP2) — governs the transport-layer dialogue between the platform (OS/browser) and the authenticator (hardware key, platform TPM, secure enclave). CTAP2 is specified in the FIDO Alliance CTAP2 specification §5.
  • Web Authentication (WebAuthn) — the W3C Level 3 specification that exposes the PublicKeyCredential browser API and defines the relying party (RP) verification algorithm. WebAuthn §7 specifies the full registration and authentication verification procedures.

The FIDO Alliance also publishes the Metadata Service (MDS3), a signed JSON blob of authenticator device metadata keyed by AAGUID. RPs consume MDS3 to validate attestation statements and enforce device posture policies during registration.

authenticatorData byte layout (WebAuthn §6.1)

Every assertion and attestation response contains an authenticatorData binary structure. Misreading its layout is one of the most common implementation bugs:

Bytes Field Description
0–31 rpIdHash SHA-256 of the RP ID string
32 flags Bitmask: bit 0 = UP, bit 2 = UV, bit 6 = AT (attested credential data present), bit 7 = ED (extensions present)
33–36 signCount Big-endian uint32; monotonically increasing counter
37+ attestedCredentialData Present when AT flag is set: AAGUID (16 bytes), credential ID length (2 bytes), credential ID, COSE public key
variable extensions Present when ED flag is set; CBOR-encoded extension map

The COSE public key encoding uses a map with integer keys. For ES256: key type (1) = 2 (EC2), algorithm (3) = -7, curve (-1) = 1 (P-256), x coordinate (-2), y coordinate (-3).


Architecture and Data Flow

The diagram below shows how CTAP2, WebAuthn, and the RP verification layer interact across the three principal actors during credential creation (registration):

FIDO2 WebAuthn Registration Architecture Sequence diagram showing how the authenticator, platform/browser (CTAP2 layer), and relying party server interact during WebAuthn registration. CTAP2 governs authenticator-to-platform communication; WebAuthn governs platform-to-RP communication. AUTHENTICATOR (TPM / Secure Enclave / YubiKey) PLATFORM / BROWSER (CTAP2 ↔ WebAuthn bridge) RELYING PARTY SERVER (WebAuthn §7 verification) ① challenge + rp.id + pubKeyParams ② CTAP2 makeCredential (clientDataHash, rp, user, algs) ③ Generate ES256 key pair Private key in secure element AAGUID → attestedCredentialData ④ attestationObject (CBOR) (fmt, attStmt, authData) ⑤ Wrap as PublicKeyCredential clientDataJSON + attestationObject ⑥ POST /registration/complete ⑦ WebAuthn §7.1 verification Decode CBOR → parse authData Verify rpIdHash, flags, attStmt ⑧ Store credentialId + pubKey CTAP2 scope WebAuthn scope

During authentication (assertion), the flow mirrors registration but replaces authenticatorMakeCredential with authenticatorGetAssertion (CTAP2 §6.2) and the RP runs the assertion verification algorithm from WebAuthn §7.2 instead of §7.1.


Implementation Guide

Step 1 — Detect platform capabilities (WebAuthn §10.1.3)

Query PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() before rendering any passkey UI. This call distinguishes devices with a hardware-backed platform authenticator from those that require a roaming key.

async function detectAuthenticatorCapabilities(): Promise<{
  platformAvailable: boolean;
  conditionalMediationAvailable: boolean;
}> {
  const [platformAvailable, conditionalMediationAvailable] = await Promise.all([
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
    PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false),
  ]);
  return { platformAvailable, conditionalMediationAvailable };
}

Never skip this check. Unconditionally offering passkeys on devices without a UVPA degrades to an error that erodes user trust.

Step 2 — Build PublicKeyCredentialCreationOptions (WebAuthn §5.4)

The RP must generate a 32-byte cryptographically random challenge server-side and embed the correct rp.id for origin binding. Mismatching rp.id against the effective domain is the most common SecurityError root cause.

import { randomBytes } from 'node:crypto';

function buildCreationOptions(userId: string, userEmail: string): PublicKeyCredentialCreationOptionsJSON {
  return {
    challenge: randomBytes(32).toString('base64url'), // server-generated; never reuse
    rp: {
      id: 'passkeywebauthn.com',  // must be registrable suffix of the origin
      name: 'PasskeyWebAuthn',
    },
    user: {
      id: Buffer.from(userId).toString('base64url'),
      name: userEmail,
      displayName: userEmail,
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7   }, // ES256 — preferred
      { type: 'public-key', alg: -8   }, // EdDSA — where supported
      { type: 'public-key', alg: -257 }, // RS256 — legacy fallback
    ],
    authenticatorSelection: {
      residentKey: 'preferred',           // enables discoverable credentials
      userVerification: 'preferred',      // request UV for AAL2
    },
    attestation: 'indirect',             // MDS3-verifiable attestation
    timeout: 120_000,
  };
}

Step 3 — Parse authenticatorData on the server (WebAuthn §6.1)

After the browser returns the PublicKeyCredential, the server must decode the raw authenticatorData buffer with exact byte offsets before any cryptographic check.

interface ParsedAuthData {
  rpIdHash: Buffer;
  flags: number;
  signCount: number;
  aaguid: string | null;
  credentialId: Buffer | null;
  cosePublicKey: Buffer | null;
}

function parseAuthenticatorData(authData: Buffer): ParsedAuthData {
  if (authData.length < 37) throw new Error('authenticatorData too short');

  const rpIdHash  = authData.subarray(0, 32);
  const flags     = authData[32];
  const signCount = authData.readUInt32BE(33);

  const UP = (flags & 0x01) !== 0; // User Presence
  const UV = (flags & 0x04) !== 0; // User Verification
  const AT = (flags & 0x40) !== 0; // Attested credential data present

  if (!UP) throw new Error('User Presence flag not set — assertion rejected');

  let aaguid: string | null = null;
  let credentialId: Buffer | null = null;
  let cosePublicKey: Buffer | null = null;

  if (AT) {
    // AAGUID: bytes 37–52 (16 bytes, formatted as UUID)
    const aaguidBytes = authData.subarray(37, 53);
    aaguid = [
      aaguidBytes.subarray(0, 4).toString('hex'),
      aaguidBytes.subarray(4, 6).toString('hex'),
      aaguidBytes.subarray(6, 8).toString('hex'),
      aaguidBytes.subarray(8, 10).toString('hex'),
      aaguidBytes.subarray(10).toString('hex'),
    ].join('-');

    const credIdLen = authData.readUInt16BE(53);
    credentialId  = authData.subarray(55, 55 + credIdLen);
    cosePublicKey = authData.subarray(55 + credIdLen);
  }

  return { rpIdHash, flags, signCount, aaguid, credentialId, cosePublicKey };
}

The AAGUID is then matched against the FIDO Alliance MDS3 to verify device authenticity during attestation.

Step 4 — Verify the RP ID hash (WebAuthn §7.1, step 9)

Before any signature check, verify that rpIdHash equals the SHA-256 of your RP ID string. This is the cryptographic mechanism that enforces origin binding and prevents cross-site credential replay.

import { createHash } from 'node:crypto';

function verifyRpIdHash(rpIdHash: Buffer, expectedRpId: string): void {
  const expected = createHash('sha256').update(expectedRpId).digest();
  if (!rpIdHash.equals(expected)) {
    throw new Error(`rpIdHash mismatch — possible cross-origin attack or rp.id misconfiguration`);
  }
}

Step 5 — Verify the assertion signature (WebAuthn §7.2, step 20)

The signed payload is the concatenation of authenticatorData and SHA-256(clientDataJSON). Route to the correct Web Crypto algorithm based on the stored COSE algorithm identifier.

import { subtle } from 'node:crypto';

type CoseAlg = -7 | -8 | -257;

function getCryptoAlgorithm(coseAlg: CoseAlg): AlgorithmIdentifier {
  const map: Record<CoseAlg, AlgorithmIdentifier> = {
    [-7]:   { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } as EcdsaParams & { namedCurve: string },
    [-8]:   { name: 'Ed25519' },
    [-257]: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } as RsaPssParams,
  };
  const alg = map[coseAlg];
  if (!alg) throw new Error(`Unsupported COSE algorithm: ${coseAlg}`);
  return alg;
}

async function verifyAssertionSignature(
  authData: ArrayBuffer,
  clientDataJSON: ArrayBuffer,
  signature: ArrayBuffer,
  spkiPublicKey: ArrayBuffer,
  coseAlg: CoseAlg,
): Promise<boolean> {
  const clientDataHash = await subtle.digest('SHA-256', clientDataJSON);

  const signedData = new Uint8Array(authData.byteLength + clientDataHash.byteLength);
  signedData.set(new Uint8Array(authData), 0);
  signedData.set(new Uint8Array(clientDataHash), authData.byteLength);

  const alg = getCryptoAlgorithm(coseAlg);
  const cryptoKey = await subtle.importKey('spki', spkiPublicKey, alg, false, ['verify']);
  return subtle.verify(alg, cryptoKey, signature, signedData);
}

Step 6 — Enforce signCount monotonicity (WebAuthn §7.2, step 17)

After a successful assertion, compare the signCount from authenticatorData with the stored counter. A non-zero counter that is less than or equal to the stored value indicates a cloned authenticator.

async function enforceSignCount(
  credentialId: string,
  newSignCount: number,
  db: DatabaseClient,
): Promise<void> {
  const { sign_count } = await db.query<{ sign_count: number }>(
    'SELECT sign_count FROM webauthn_credentials WHERE credential_id = $1',
    [credentialId],
  );

  if (newSignCount !== 0 && newSignCount <= sign_count) {
    // Authenticator cloning detected — invalidate the credential
    await db.query(
      'UPDATE webauthn_credentials SET revoked = TRUE WHERE credential_id = $1',
      [credentialId],
    );
    throw new Error(`signCount regression detected (stored: ${sign_count}, received: ${newSignCount}) — credential revoked`);
  }

  await db.query(
    'UPDATE webauthn_credentials SET sign_count = $1 WHERE credential_id = $2',
    [newSignCount, credentialId],
  );
}

Validation Checklist

Use this checklist for every registration and authentication endpoint:

Registration (WebAuthn §7.1)

Authentication (WebAuthn §7.2)


Error Reference Table

Error HTTP Status Trigger Condition Diagnostic
DOMException: NotAllowedError User dismissed prompt, timeout exceeded, or no qualifying credential found Check timeout value; verify credential exists for allowCredentials filter
DOMException: SecurityError rp.id is not a registrable suffix of the origin, or page is not in a secure context Must be HTTPS; rp.id cannot exceed the effective domain
DOMException: InvalidStateError excludeCredentials match found — credential already registered for this authenticator Expected behaviour; surface a “you already have a passkey on this device” message
DOMException: NotSupportedError No pubKeyCredParams algorithm is supported by the authenticator Add RS256 (-257) as a fallback
rpIdHash mismatch 400 rp.id in creation options differs from rp.id used in verification Both must be identical strings; check for trailing slashes or subdomain mismatches
signCount regression 403 Authenticator counter ≤ stored counter (non-zero) Treat as cloned device; revoke credential and prompt re-registration
Attestation format unknown 400 fmt value not in packed, tpm, android-key, apple, fido-u2f, none Log the raw fmt; reject with 400 until you add the parser
COSE algorithm mismatch 400 alg in attStmt differs from the key’s COSE algorithm header Verify pubKeyCredParams was honoured; reject and log AAGUID

Platform and Library Notes

@simplewebauthn/server (Node.js)

verifyRegistrationResponse and verifyAuthenticationResponse handle authenticatorData parsing, COSE key decoding, and signCount tracking in one call. Pass the raw Buffer from response.attestationObject; the library decodes CBOR internally. Set requireUserVerification: true to enforce the UV flag for AAL2 flows. Supports ES256, EdDSA, and RS256 natively.

fido2-lib (Node.js)

Requires manual CBOR decoding before passing to assertionResult. Use the cbor package to decode attestationObject; pass the resulting Map as response.authnrData. Does not auto-validate MDS3 — wire in Fido2Lib.addAttestationFormat and fetch the MDS3 blob yourself.

py_webauthn (Python)

verify_registration_response accepts expected_rp_id and expected_origin as explicit arguments — never derive these from the request headers. The library parses authenticatorData and validates rpIdHash internally. Upgrade to ≥ 1.11.0 for EdDSA support.

WebAuthn4J (Java/Kotlin)

Uses the CoreRegistrationRequest / CoreAuthenticationRequest pair. Pass the stored AttestedCredentialData object from registration into CoreAuthenticationRequest to enable signCount enforcement. Enable DCAPolicyEnforcementFilter for MDS3-backed attestation trust anchor resolution.

Platform quirks

iOS Safari (≥ 16): Platform passkeys are stored in iCloud Keychain and synced across Apple devices. The AAGUID for iCloud Keychain sync credentials is aaguid:00000000-0000-0000-0000-000000000000 — do not interpret this as an error; it is intentional. attestation: 'direct' returns apple format only on physical devices, not the Simulator.

Windows Hello: AAGUID varies by TPM manufacturer and Hello version. TPM-backed keys use tpm attestation format; software keys use none. Always resolve AAGUID via MDS3 rather than hardcoding expected values.

Android (FIDO2 via Play Services): Uses android-key or android-safetynet attestation depending on API level. From API 28+ with StrongBox, the AAGUID is present in the attestation extension of the signing certificate. Cross-device authentication via hybrid transport (hybrid) uses a QR code flow and returns a different AAGUID than the platform passkey flow.


Pitfalls and Security Hardening

1. Conflating FIDO U2F (CTAP1) with FIDO2 (CTAP2)

U2F credentials use a different registration and assertion wire format. Attempting to verify a CTAP1 assertion with WebAuthn §7.2 logic silently fails or throws a parsing error. Identify CTAP1 credentials by checking for fido-u2f attestation format and route to the legacy verification path separately.

2. Accepting attestation: 'none' unconditionally in enterprise deployments

none removes all device trust guarantees. For regulated environments (PCI DSS 4.0.1 Req 8.3, HIPAA), require indirect or direct attestation and validate against MDS3. Reject unknown AAGUIDs or flag them for manual review.

3. Not binding challenges to sessions

A challenge must be issued per-request, stored server-side tied to the user session, and invalidated immediately after use — regardless of success or failure. Storing challenges client-side (e.g., in localStorage) enables replay attacks. Enforce a 120-second TTL maximum.

4. Ignoring the UV flag for high-assurance flows

User Presence (UP, bit 0) only confirms physical interaction with the authenticator. User Verification (UV, bit 2) additionally confirms that the user authenticated to the authenticator (biometric/PIN). NIST SP 800-63B AAL2 requires UV. Do not treat UP-only as sufficient for password replacement flows.

5. Storing credential IDs as stable user identifiers

credential.id is authenticator-specific. A user may register multiple passkeys across devices — each with a distinct credential.id. Use your own user.id as the stable account identifier; treat credential.id as a lookup key into the webauthn_credentials table.

6. Skipping MDS3 AAGUID resolution

The AAGUID in attestedCredentialData links the credential to a specific authenticator model. Without MDS3 lookup, you cannot enforce device posture policies, detect authenticators with known vulnerabilities, or satisfy FIDO2 Level 2 certification requirements. Fetch the MDS3 blob weekly, cache locally, and resolve on every registration.


Related