Step-by-Step Breakdown of the FIDO2 Registration Flow

FIDO2 registration is a five-phase cryptographic handshake that ends with the relying party holding a verified public key it can use for all future authentications — no password ever travels the wire. This page isolates each phase, shows the exact error signatures you will hit when a phase fails, and provides TypeScript code you can drop into a production server. For the architectural context this flow fits inside, see The Challenge-Response Authentication Flow.


Error and Constraint Reference Table

Scan this table first to match the symptom you are debugging, then jump to the corresponding phase section below.

Phase Error / HTTP status Trigger condition Fast diagnostic
1 — Options SecurityError: Invalid origin rp.id does not match page hostname or TLS SAN openssl s_client → check SAN
1 — Options NotAllowedError: User gesture required create() called outside a qualifying event handler Wrap in click listener
2 — Authenticator InvalidStateError: Credential already exists excludeCredentials not populated Query DB for existing IDs first
2 — Authenticator NotSupportedError Algorithm not supported by authenticator firmware Add -257 (RS256) as fallback
2 — Authenticator AbortError: User cancelled Cross-origin iframe, no permission policy Add allow="publickey-credentials-create" to iframe
3 — Server decode HTTP 400 InvalidAttestationSignature clientDataJSON.type !== 'webauthn.create' Log raw clientDataJSON before hashing
3 — Server decode HTTP 400 rpIdHashMismatch authData[0..31] !== SHA-256(expectedRPID) echo -n "rp.id" | openssl dgst -sha256
4 — Attestation HTTP 400 CertificateRevoked AAGUID found in MDS3 revocation list Query FIDO MDS3 /metadata/ endpoint
5 — Storage HTTP 500 StorageConstraintViolation credentialId stored as VARCHAR, truncated Use BYTEA column type
5 — Storage HTTP 422 MissingPublicKey publicKey not extracted from registrationInfo Check library version — v11+ nests under registrationInfo.credential

Root Cause Analysis

Phase 1: Invalid origin and challenge entropy failures

The most common Phase 1 failure is an rp.id mismatch. The WebAuthn spec (W3C WebAuthn §7.1 step 7) requires that rp.id be a registrable domain suffix of the effective domain. If your page is at app.example.com but you set rp.id: "www.example.com", the browser rejects the call with SecurityError before it even reaches the authenticator.

Challenge entropy failures are silent until replay attacks succeed: any challenge shorter than 16 bytes, or reused across sessions, violates the challenge-response flow’s replay-prevention guarantee. NIST SP 800-63B §5.1.2 and the FIDO2 specification both require at least 16 bytes from a CSPRNG; 32 bytes is the production standard.

Phase 2: Authenticator CTAP2 handshake failures

InvalidStateError means the browser found an existing credential for this user on this authenticator — you did not pass excludeCredentials. The authenticator checks this list during CTAP2 authenticatorMakeCredential and will reject the call to prevent silent key overwrites that would break existing sessions.

NotSupportedError on the algorithm indicates the authenticator’s firmware does not implement the only algorithm in pubKeyCredParams. Most hardware keys from 2019 and earlier support ES256 (-7) but not EdDSA (-8). Always include RS256 (-257) as a fallback so legacy keys can enroll.

Phase 3 and 4: Server-side decoding and attestation verification failures

rpIdHashMismatch is the canonical server-side failure. The authenticatorData blob (defined in W3C WebAuthn §6.1) starts with a 32-byte SHA-256 hash of the RP ID. If the server computes SHA-256("app.example.com") but the browser sent SHA-256("example.com"), every byte comparison fails. This is almost always a misconfigured expectedRPID in the server library, not a client bug.

InvalidAttestationSignature arises when clientDataJSON.type is not exactly the string "webauthn.create" — a frequent mistake is calling the authentication assertion path ("webauthn.get") against the registration endpoint.

Phase 5: Storage failures

Binary data stored in a VARCHAR or TEXT column is silently corrupted during Base64URL round-trips when database drivers assume UTF-8. The credentialId and COSE-encoded publicKey must be stored as BYTEA (PostgreSQL) or BLOB (MySQL/SQLite) to guarantee byte-exact retrieval at authentication time.


Step-by-Step Resolution

Step 1 — Construct valid PublicKeyCredentialCreationOptions

Generate a 32-byte challenge with crypto.getRandomValues(), bind rp.id to the exact hostname, and configure userVerification: 'required' to satisfy NIST SP 800-63B AAL2.

// Must be invoked inside a qualifying user-gesture handler
document.getElementById('register-btn')!.addEventListener('click', async () => {
  const challenge = crypto.getRandomValues(new Uint8Array(32));

  const options: PublicKeyCredentialCreationOptions = {
    challenge,
    rp: {
      id: window.location.hostname,  // must match TLS SAN exactly
      name: 'Example App'
    },
    user: {
      id: crypto.getRandomValues(new Uint8Array(16)), // opaque user handle
      name: '[email protected]',
      displayName: 'Alice Example'
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7   },  // ES256  — preferred
      { type: 'public-key', alg: -257 }   // RS256  — legacy key fallback
    ],
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'required'
    },
    timeout: 60_000,
    attestation: 'direct'
  };

  const credential = await navigator.credentials.create({ publicKey: options });
  await sendToServer(credential);
});

Verify the TLS certificate SAN matches rp.id before deploying:

openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
  | openssl x509 -noout -text | grep "Subject Alternative Name"

Step 2 — Prevent duplicate enrollment with excludeCredentials

Query all existing credential IDs for the user before constructing options, and populate excludeCredentials. This triggers CTAP2’s duplicate-detection logic on the authenticator before any new key material is generated.

const existingCredentials = await db.credentials.findAll({
  where: { userId: user.id },
  attributes: ['credentialId', 'transports']
});

const excludeCredentials: PublicKeyCredentialDescriptor[] = existingCredentials.map(c => ({
  type: 'public-key',
  id: c.credentialId,          // Buffer/Uint8Array — not base64 string
  transports: c.transports as AuthenticatorTransport[]
}));

Check platform authenticator availability before rendering the UI, so you can prompt correctly on devices that lack biometrics:

const platformAvailable =
  await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
console.log('Platform UV authenticator:', platformAvailable);

Step 3 — Decode authenticatorData and verify rpIdHash on the server

The server receives the PublicKeyCredential serialised as JSON. Before any signature check, verify the structural invariants of clientDataJSON and authenticatorData.

import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
  response: parsedCredential,               // JSON-parsed credential from client
  expectedChallenge: sessionChallenge,      // base64url string from session store
  expectedOrigin: 'https://app.example.com',
  expectedRPID:   'app.example.com',
  requireUserVerification: true
});

if (!verification.verified) {
  throw new Error('Attestation validation failed');
}

To diagnose rpIdHashMismatch manually, recompute the expected hash and compare to bytes 0–31 of the raw authData:

# Expected rpIdHash (hex) — compare to authData[0..31]
echo -n "app.example.com" | openssl dgst -sha256

Step 4 — Validate the attestation statement and AAGUID

After @simplewebauthn/server v11+, attestation metadata lives under registrationInfo:

const { credential, aaguid, attestationObject } = verification.registrationInfo;
// credential = { id: Uint8Array, publicKey: Uint8Array, counter: number, transports: string[] }
// aaguid = UUID string identifying the authenticator model

// Optional: cross-reference AAGUID against FIDO MDS3 for revocation and certification level
const metadataEntry = await fetchFromMDS3(aaguid);
if (metadataEntry?.statusReports.some(r => r.status === 'REVOKED')) {
  throw new Error(`Authenticator AAGUID ${aaguid} is revoked in MDS3`);
}

Validate the UP (User Present) and UV (User Verified) flags in authData byte 32 (flags byte):

Bit Flag Meaning
0 UP User was physically present
2 UV User was verified (biometric or PIN)
6 AT Attested credential data included

UV must be set when userVerification: 'required' was specified. Reject registrations where it is clear.

Step 5 — Store the credential with correct column types

await db.credentials.create({
  credentialId: Buffer.from(credential.id),  // Uint8Array → Buffer → BYTEA
  publicKey:    Buffer.from(credential.publicKey), // COSE-encoded key → BYTEA
  signCount:    credential.counter,          // may be 0 for platform authenticators
  userId:       user.id,
  aaguid,
  transports:   credential.transports ?? [],
  createdAt:    new Date()
});

Verify column types match after migration:

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'credentials'
  AND column_name IN ('credential_id', 'public_key', 'sign_count');
-- Expected: credential_id → bytea, public_key → bytea, sign_count → integer

Registration Flow Diagram

The SVG below traces the five-phase handshake between browser, authenticator, and relying party server.

FIDO2 Registration Sequence Diagram Sequence diagram showing the five phases of FIDO2 registration: options construction, CTAP2 key generation, attestationObject transmission, server-side CBOR decode and rpIdHash verification, and credential storage. Browser / Client Authenticator RP Server GET /register/options PublicKeyCredentialCreationOptions (challenge, rp, user) CTAP2 authenticatorMakeCredential attestationObject + clientDataJSON POST /register/complete (PublicKeyCredential) CBOR decode authData verify rpIdHash[0..31] check UP+UV flags verify attStmt signature look up AAGUID in MDS3 check revocation status store credentialId BYTEA store publicKey BYTEA record signCount = 0 HTTP 201 — registration complete

Verification and Testing

Conformance tool check

The FIDO Alliance conformance test suite can validate your registration endpoint against the full WebAuthn specification. Run the test harness against https://localhost:8443/register and confirm all MakeCredential test vectors pass, including the packed and none attestation format cases.

Unit test assertions for server-side verification

import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { expect, test } from 'vitest';
import { testVector } from './vectors/registration-packed.json';

test('verifies packed attestation with ES256', async () => {
  const result = await verifyRegistrationResponse({
    response: testVector.credential,
    expectedChallenge: testVector.challenge,
    expectedOrigin:    testVector.origin,
    expectedRPID:      testVector.rpId,
    requireUserVerification: true
  });

  expect(result.verified).toBe(true);
  expect(result.registrationInfo.credential.counter).toBeGreaterThanOrEqual(0);
  expect(result.registrationInfo.aaguid).toMatch(
    /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
  );
});

Manual rpIdHash verification

# Compute expected rpIdHash — compare hex output to authData bytes 0–31
echo -n "app.example.com" | openssl dgst -sha256
# SHA2-256(stdin)= d7e9...

# Decode a base64url-encoded authData and dump first 32 bytes as hex
node -e "
  const b = Buffer.from('<base64url_authData>', 'base64url');
  console.log('rpIdHash:', b.slice(0, 32).toString('hex'));
  console.log('flags byte:', b[32].toString(2).padStart(8, '0'));
"

The flags byte output should have bit 0 (UP) and bit 2 (UV) set when userVerification: 'required' was specified. A flags byte of 01000101 (binary) means UP=1, UV=1, AT=1.

Conditional mediation check

const conditionalSupported =
  await PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
console.log('Conditional mediation (passkey autofill):', conditionalSupported);

Pitfalls Specific to FIDO2 Registration

1. Reusing challenges across requests. The challenge must be single-use and stored server-side with an expiry of 60–120 seconds. A reused challenge allows a captured attestationObject to be replayed by an attacker who intercepts the POST body. Bind the challenge to the server session and delete it after the first successful verification.

2. Omitting the AT flag check. When attestation: 'direct' or 'indirect' is requested, the AT flag (bit 6 of the flags byte) must be set in authenticatorData. Skipping this check allows a client to submit a bare assertion response through the registration endpoint.

3. Storing signCount without checking it at login. The initial signCount value returned during registration is the baseline for signature counter drift detection at every subsequent authentication. If signCount is not stored — or stored incorrectly — you lose the ability to detect cloned authenticators, which is the primary defence that signCount provides.


Related