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
PublicKeyCredentialbrowser 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):
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
- WebAuthn & FIDO2 Protocol Fundamentals — parent section covering the full protocol ecosystem
- The Challenge-Response Authentication Flow — full verification pipeline for both registration and assertion
- Attestation vs Assertion Explained — deep dive into the two distinct credential operation types
- Relying Party and Authenticator Roles — trust model and RP ID scope rules in detail
- How WebAuthn Prevents Phishing Attacks — origin binding mechanics, MDS3 attestation enforcement, and CI/CD validation patterns
- Public Key vs Symmetric Credential Types — asymmetric key storage and GDPR data-minimisation implications