Backend Verification and Secure Credential Storage

Deploying passkeys in production requires far more than wiring up a WebAuthn client library and persisting a public key. The backend carries the entire burden of trust: it generates cryptographically secure challenges, validates authenticatorData byte-by-byte, enforces signCount monotonicity, stores COSE-encoded public keys with the correct algorithm metadata, and manages the full credential lifecycle from registration through revocation. This reference covers the architectural patterns, cryptographic workflows, and operational controls required to build that backend correctly. It targets full-stack developers, security engineers, identity platform builders, and compliance officers responsible for deploying phishing-resistant authentication at scale.

Protocol and Architecture Overview

WebAuthn sits at the intersection of three interlocking specifications. The W3C Web Authentication API (Level 2, https://www.w3.org/TR/webauthn-2/) defines the JavaScript API and the data structures that flow between browser and relying party. FIDO2/CTAP2 (Client-to-Authenticator Protocol 2) governs the wire protocol between the platform and the authenticator hardware. The FIDO Alliance Metadata Service (MDS3) supplies authenticator metadata — AAGUID-to-device-model mappings, security certification levels, and known vulnerability advisories — that the RP uses during attestation validation.

The trust model partitions responsibility across three layers:

  • Authenticator layer: Generates and holds private key material in a secure element or platform TPM. Signs authenticatorData || SHA-256(clientDataJSON) during both registration and authentication. Biometric data never leaves this layer.
  • Client layer: The browser or native app that mediates between user and authenticator. Constructs clientDataJSON, enforces the origin binding, and calls navigator.credentials.create() / navigator.credentials.get().
  • Relying Party layer: The backend that issues challenges, validates all returned data structures against the spec, and owns the authoritative credential store.
WebAuthn Trust Model — Three-Layer Architecture Diagram showing the authenticator layer on the left communicating via CTAP2 to the client (browser) layer in the centre, which communicates via HTTPS to the relying party (backend) layer on the right. Each layer lists its key responsibilities. Authenticator (CTAP2 / platform TPM) • Private key generation • Biometric / PIN verify • Signs authenticatorData • AAGUID identifies model • attestationObject • Secure enclave storage Client (Browser) (W3C WebAuthn API) • Constructs clientDataJSON • Enforces origin binding • credentials.create/get() • Conditional mediation UI • PublicKeyCredential obj • No private key access Relying Party (Backend / RP Server) • CSPRNG challenge gen • Verifies attestation • Validates signCount • COSE public key store • Session management • MDS3 attestation trust CTAP2 HTTPS challenge W3C WebAuthn Level 2 · FIDO2/CTAP2 · FIDO Alliance MDS3

Core Concepts

Challenge Generation and Binding

The RP must generate each challenge with a CSPRNG (§7.1 step 1 of the WebAuthn spec). A 16-byte minimum is required; 32 bytes is the production standard. The challenge is base64url-encoded, stored server-side with a short TTL (60–120 seconds), and bound to the user session or a one-time token to prevent replay. The challenge-response authentication flow goes into depth on the full lifecycle.

import { randomBytes } from "crypto";

function generateChallenge(): string {
  // §7.1 step 1: CSPRNG, minimum 16 bytes; 32 bytes recommended
  return randomBytes(32).toString("base64url");
}

async function storeChallenge(
  sessionId: string,
  challenge: string,
  ttlSeconds = 120
): Promise<void> {
  await redis.setEx(`webauthn:challenge:${sessionId}`, ttlSeconds, challenge);
}

authenticatorData Structure

authenticatorData is a 37-byte-minimum binary structure defined in WebAuthn §6.1. The RP must parse it byte-by-byte during both registration and authentication:

Bytes Field Notes
0–31 rpIdHash SHA-256 of the RP ID — must match SHA-256(rpId)
32 flags Bitmask: UP (0x01), UV (0x04), AT (0x40), ED (0x80)
33–36 signCount Big-endian uint32; must increment on each assertion
37+ attestedCredentialData Present when AT flag is set (registration only)
var extensions Present when ED flag is set
function parseAuthenticatorData(authDataBuffer: Buffer) {
  const rpIdHash = authDataBuffer.subarray(0, 32);
  const flags = authDataBuffer[32];
  const signCount = authDataBuffer.readUInt32BE(33);
  const upFlag = (flags & 0x01) !== 0;   // user presence
  const uvFlag = (flags & 0x04) !== 0;   // user verification
  const atFlag = (flags & 0x40) !== 0;   // attested credential data present
  return { rpIdHash, flags, signCount, upFlag, uvFlag, atFlag };
}

COSE Public Key Decoding and Algorithm Routing

Authenticators encode public keys in COSE (CBOR Object Signing and Encryption) format, defined in RFC 8152. The alg parameter (COSE map key -3 / CBOR key 3) determines verification logic. Storing the algorithm ID alongside the raw key bytes is mandatory — without it the RP cannot route to the correct verification path at assertion time.

COSE alg Value Algorithm Key type
ES256 -7 ECDSA P-256 + SHA-256 EC2, crv P-256
RS256 -257 RSA PKCS#1 v1.5 + SHA-256 RSA
EdDSA -8 Ed25519 OKP, crv Ed25519
import { cborDecode } from "./cbor";
import { createVerify } from "crypto";

function verifyCOSESignature(
  cosePublicKey: Buffer,
  signedData: Buffer,   // authData || SHA-256(clientDataJSON)
  signature: Buffer
): boolean {
  const keyMap = cborDecode(cosePublicKey) as Map<number, unknown>;
  const alg = keyMap.get(3) as number; // COSE key param 3 = alg

  if (alg === -7) {
    // ES256: ECDSA P-256
    const x = keyMap.get(-2) as Buffer;
    const y = keyMap.get(-3) as Buffer;
    const uncompressed = Buffer.concat([Buffer.from([0x04]), x, y]);
    const verifier = createVerify("SHA256").update(signedData);
    return verifier.verify({ key: uncompressed, format: "der", type: "spki" }, signature);
  }
  if (alg === -257) {
    // RS256: RSA PKCS#1 v1.5
    const n = keyMap.get(-1) as Buffer;
    const e = keyMap.get(-2) as Buffer;
    const verifier = createVerify("SHA256").update(signedData);
    return verifier.verify({ key: { n, e }, format: "jwk", kty: "RSA" }, signature);
  }
  throw new Error(`Unsupported COSE algorithm: ${alg}`);
}

signCount Enforcement

The signCount is a monotonically increasing counter maintained by the authenticator and stored by the RP. After each successful assertion, the RP compares the returned signCount with its stored value. Per WebAuthn §7.2 step 17: if the stored signCount is non-zero and the received value is less than or equal to the stored value, the RP must treat this as a signal of cloning or credential replay and abort.

async function enforceSignCount(
  credentialId: string,
  receivedCount: number
): Promise<void> {
  const stored = await db.getSignCount(credentialId);
  if (stored > 0 && receivedCount <= stored) {
    // §7.2 step 17: possible cloning attack — invalidate credential
    await db.revokeCredential(credentialId, "sign_count_anomaly");
    throw new Error(`signCount anomaly: stored=${stored}, received=${receivedCount}`);
  }
  await db.updateSignCount(credentialId, receivedCount);
}

Attestation Statement Validation

Attestation vs assertion serves a crucial architectural distinction. Attestation (during registration) provides cryptographic proof of authenticator provenance. The attestationObject is CBOR-encoded and contains a fmt field (e.g. "packed", "tpm", "android-key", "none") and an attStmt structure. For "packed" format, the RP verifies the attestation statement signature over authData || SHA-256(clientDataJSON) using the certificate chain from the x5c field, then cross-references the leaf certificate’s AAGUID against MDS3 metadata. See validating attestation statements on the server for the full verification procedure.

rpId Binding and Origin Validation

Two binding checks prevent phishing relay attacks. First, the RP computes SHA-256(rpId) and compares it against authData[0..31]. Second, it validates that clientDataJSON.origin matches the expected RP origin (scheme + host + port). Cross-subdomain rpId values must be explicitly configured; the RP ID must be a registrable domain suffix of or equal to the origin’s effective domain (WebAuthn §7.3). For the full role of the relying party in these checks, see relying party and authenticator roles.

Registration and Authentication Workflows

Registration Pipeline

  1. Challenge issuance: RP generates a 32-byte CSPRNG challenge, stores it with TTL, returns PublicKeyCredentialCreationOptions including rp.id, rp.name, user.id (opaque identifier, not email), pubKeyCredParams (ES256 first, RS256 fallback), timeout, and authenticatorSelection.
  2. Client interaction: Browser calls navigator.credentials.create(options), mediates with the authenticator over CTAP2, returns a PublicKeyCredential containing attestationObject and clientDataJSON.
  3. Backend verification steps (§7.1):
    • Decode clientDataJSON (UTF-8); parse as JSON.
    • Verify type === "webauthn.create".
    • Verify challenge matches stored value; consume and delete it.
    • Verify origin matches expected RP origin.
    • Decode and parse attestationObject (CBOR).
    • Extract and parse authData; verify rpIdHash.
    • Verify UP flag is set; verify UV flag if userVerification: "required".
    • If AT flag is set, decode attestedCredentialData; extract AAGUID, credentialId, and COSE public key.
    • Validate credentialId is globally unique in the credential store.
    • Validate attestation statement against fmt.
    • Persist: credential_id, cose_public_key, alg, sign_count, aaguid, transports, user_id.
  4. Idempotency: Bind challenge to a request fingerprint. Duplicate submissions within TTL return the existing credential record without creating a second entry.

Authentication Pipeline

  1. Challenge issuance: RP generates a fresh challenge, stores it against the session; returns PublicKeyCredentialRequestOptions with allowCredentials (populated from stored credential IDs for the user) and userVerification.
  2. Client interaction: Browser calls navigator.credentials.get(options) — or uses the Conditional Mediation API (mediation: "conditional") for autofill-driven passkey prompts.
  3. Backend verification steps (§7.2):
    • Decode and parse clientDataJSON; verify type === "webauthn.get".
    • Verify challenge and origin.
    • Look up credentialId in the store; retrieve stored public key and signCount.
    • Parse authData; verify rpIdHash, UP flag, and UV flag.
    • Compute verification data: authData || SHA-256(clientDataJSON).
    • Verify signature using the stored COSE public key.
    • Enforce signCount monotonicity (§7.2 step 17).
    • Update stored signCount; issue session token.

Validation and Security Boundaries

Every registration and authentication request must pass the following checks before the credential is trusted or a session is issued:

DOMException and Server Error Mapping

Client error Likely cause Server-side indicator
NotAllowedError User cancelled; gesture timeout; allowCredentials mismatch 4xx with code: "NO_CREDENTIAL"
SecurityError rpId not a valid suffix of origin; mixed content 400 with code: "RPID_MISMATCH"
InvalidStateError Credential already registered (excludeCredentials hit) 409 with code: "CREDENTIAL_EXISTS"
NotSupportedError Requested algorithm unsupported by authenticator 400 with code: "ALG_UNSUPPORTED"
AbortError AbortController signal fired before completion — (client-side only)
Signature verification fail Cloned credential; corrupted key bytes 401 with code: "SIGNATURE_INVALID"
signCount anomaly Cloned authenticator; replay attack 401 with code: "SIGN_COUNT_ANOMALY"

Database Architecture and Credential Indexing

Schema Design

The credential table maps authenticator-generated material to user accounts with strict data isolation from PII:

-- Core credential table (PostgreSQL)
CREATE TABLE webauthn_credentials (
  credential_id    BYTEA        NOT NULL,
  user_id          UUID         NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  cose_public_key  BYTEA        NOT NULL,   -- raw CBOR-encoded COSE key
  alg              INTEGER      NOT NULL,   -- COSE algorithm ID (-7, -257, -8)
  sign_count       BIGINT       NOT NULL DEFAULT 0,
  aaguid           UUID,                    -- from attestedCredentialData
  transports       TEXT[]       DEFAULT '{}',
  backup_eligible  BOOLEAN      NOT NULL DEFAULT FALSE,
  backup_state     BOOLEAN      NOT NULL DEFAULT FALSE,
  created_at       TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  last_used_at     TIMESTAMPTZ,
  revoked_at       TIMESTAMPTZ,
  revocation_reason TEXT,

  CONSTRAINT pk_credential PRIMARY KEY (credential_id)
);

-- O(1) lookup during assertion
CREATE UNIQUE INDEX idx_credential_id ON webauthn_credentials (credential_id);
-- User credential management and enumeration
CREATE INDEX idx_user_credentials ON webauthn_credentials (user_id) WHERE revoked_at IS NULL;
-- AAGUID-based compliance and device audits
CREATE INDEX idx_aaguid ON webauthn_credentials (aaguid) WHERE aaguid IS NOT NULL;

Composite indexing on (user_id, revoked_at) keeps active-credential queries efficient at scale. For advanced indexing strategies, see implementing credential ID lookup at scale and the parent credential indexing and database schema design reference.

Public Key Storage and Rotation

WebAuthn key rotation is always user-initiated — the RP cannot instruct an authenticator to generate a new key pair. Rotation policies are implemented by prompting users to register a replacement credential, verifying the new registration, and then either soft-deleting or hard-revoking the previous entry. For how to store WebAuthn public keys in PostgreSQL including key encoding and migration patterns, see the dedicated reference. The broader handling public key storage and rotation page covers multi-algorithm key versioning.

async function rotateCredential(
  userId: string,
  newCredentialId: Buffer,
  newCosePublicKey: Buffer,
  newAlg: number,
  legacyCredentialId: Buffer
): Promise<void> {
  await db.transaction(async (tx) => {
    // Register new credential atomically
    await tx.insertCredential({ userId, credentialId: newCredentialId, cosePublicKey: newCosePublicKey, alg: newAlg });
    // Soft-revoke legacy credential; keep row for audit trail
    await tx.revokeCredential(legacyCredentialId, "user_initiated_rotation");
  });
}

Cross-Platform and Cross-Device Considerations

Platform Passkey sync authenticatorAttachment Conditional mediation Notes
iOS Safari ≥16 iCloud Keychain "platform" Supported (iOS 16+) Requires iCloud Keychain enabled in settings
Android Chrome ≥108 Google Password Manager "platform" Supported Sync tied to Google Account; hybrid transport via BLE
Windows Hello (Chrome/Edge) No cross-device sync by default "platform" Supported Windows 11 22H2+ adds sync via Microsoft account
Roaming key (FIDO U2F / CTAP2) None — device-bound "cross-platform" Not applicable signCount expected to increment on every assertion
macOS Safari ≥16 iCloud Keychain "platform" Supported Shares passkeys with iOS via iCloud
Firefox (all platforms) No native passkey sync "cross-platform" Partial (FF 119+) Relies on roaming authenticators or OS credential managers

Sync propagation caveats: When a user registers a passkey on one device, replication to other enrolled devices may take seconds to several minutes depending on network conditions and provider sync intervals. The RP must not assume immediate cross-device availability. Back-up eligibility is signalled in authData.flags via the BE flag (0x08); back-up state by the BS flag (0x10). Store both in the backup_eligible and backup_state columns and surface device management UI accordingly.

Conditional mediation (mediation: "conditional") renders an autofill-driven passkey prompt inside native username inputs without requiring an explicit user gesture. The RP should call PublicKeyCredentialRequestOptions with an empty allowCredentials array (discoverable credential flow) so the platform can enumerate available passkeys. Not all browsers support PublicKeyCredential.isConditionalMediationAvailable() — check before invoking.

Compliance Mapping

Pillar concept NIST SP 800-63B FIDO2 Security Requirements PSD2 SCA GDPR
CSPRNG challenge (≥128-bit entropy) AAL2 §5.1.9 R2 nonce requirements Dynamic linking element
User verification (uvFlag enforced) AAL3 §5.1.9 Authenticator Certification “Inherence + knowledge” SCA
signCount monotonicity AAL2 §5.1.7.1 Cloning detection (R10) Fraud detection requirement
Attestation + MDS3 validation AAL3 §5.1.9.1 Authenticator Certification Data minimisation (Art. 5)
AAGUID storage (not biometric data) Art. 9 biometric data exclusion
Immutable audit log of credential events AAL2 §7.1 Transaction logging Art. 30 records of processing
Credential revocation + session invalidation AAL2 §7.2.3 Right to erasure (Art. 17)
rpIdHash binding (anti-phishing) AAL2 §5.2.5 Phishing resistance (R1)

FIDO2 certification alignment requires conformance testing against the WebAuthn Level 2 specification and FIDO Alliance MDS3. Biometric data never reaches the RP; the backend stores only COSE public keys, AAGUID, and anonymized transport metadata. Structured logs must use PII redaction before emission — credential IDs and challenge bytes should never appear in plaintext application logs.

Common Pitfalls

  1. Cross-environment rpId mismatch. Staging environments that share a different subdomain from production will produce SecurityError at the client if the rpId is not explicitly matched to the effective domain. Mitigation: configure rpId explicitly in environment variables; never default to window.location.hostname.

  2. Consuming the challenge only on success. If the challenge is deleted after a failed verification (expired TTL, wrong type), legitimate retry attempts fail. Mitigation: consume the challenge only on cryptographic verification success; on validation failure, return a structured error without consuming the challenge — unless the failure was a replay attempt, in which case consume immediately.

  3. Storing algorithm-less public keys. Without the COSE alg stored in the database, the RP must re-parse the CBOR key on every assertion to determine algorithm routing. This is both fragile and slow. Mitigation: always persist alg (INTEGER) as a dedicated column at registration time.

  4. Ignoring signCount for synced passkeys. Synced passkeys (iCloud Keychain, Google Password Manager) may report signCount = 0 on every assertion because the counter is not preserved across sync. Treating zero-count as a cloning attack will block legitimate users. Mitigation: when both stored and received signCount are 0, skip monotonicity enforcement but log the event; enforce strictly when stored signCount > 0.

  5. Omitting userVerification: "required" in high-security flows. Without UV enforcement, a UV-capable authenticator may fall back to UP-only verification (PIN-less or biometric-bypassed). Mitigation: set userVerification: "required" and verify the UV flag is set in authData.flags before issuing the session.

  6. Email-only account recovery. A recovery path that requires only a verified email re-exposes the account to phishing and SIM-swapping — the threats passkeys are designed to eliminate. Mitigation: require step-up verification (a second enrolled passkey or admin multi-party approval) before deleting all credentials and issuing a recovery token. See credential revocation and account recovery for recovery state machine design.

  7. Returning raw attestation data in API responses. Including base64url-encoded attestationObject, clientDataJSON, or challenges in HTTP responses increases attack surface for credential harvesting. Mitigation: strip all attestation material server-side before returning a registration success response.

  8. Race conditions on concurrent multi-device registration. If two devices complete registration within the same request window, the excludeCredentials check may fail to catch duplicates if the first credential is not yet committed to the primary replica. Mitigation: enforce a unique constraint on credential_id at the database level and handle 23505 (PostgreSQL duplicate key) as a 409 conflict.

  9. Mutable audit logs. Audit trails for credential creation, modification, and deletion must be append-only. Mutable logs prevent forensic reconstruction of the credential lifecycle during security incidents and fail SOC 2 audit requirements. Mitigation: write credential events to an immutable log store (append-only table with INSERT permissions only, or an external SIEM).

  10. Session fixation after passkey binding. Failing to regenerate the session ID immediately after a successful WebAuthn assertion allows an attacker who obtained the pre-authentication session token to inherit the authenticated session. Mitigation: always call session.regenerate() (or equivalent) before attaching the authenticated user to the session. For full session architecture, see server-side session management with passkeys.

Related