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 theoriginbinding, and callsnavigator.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.
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
- Challenge issuance: RP generates a 32-byte CSPRNG challenge, stores it with TTL, returns
PublicKeyCredentialCreationOptionsincludingrp.id,rp.name,user.id(opaque identifier, not email),pubKeyCredParams(ES256 first, RS256 fallback),timeout, andauthenticatorSelection. - Client interaction: Browser calls
navigator.credentials.create(options), mediates with the authenticator over CTAP2, returns aPublicKeyCredentialcontainingattestationObjectandclientDataJSON. - Backend verification steps (§7.1):
- Decode
clientDataJSON(UTF-8); parse as JSON. - Verify
type === "webauthn.create". - Verify
challengematches stored value; consume and delete it. - Verify
originmatches expected RP origin. - Decode and parse
attestationObject(CBOR). - Extract and parse
authData; verifyrpIdHash. - Verify UP flag is set; verify UV flag if
userVerification: "required". - If
ATflag is set, decodeattestedCredentialData; extract AAGUID,credentialId, and COSE public key. - Validate
credentialIdis globally unique in the credential store. - Validate attestation statement against
fmt. - Persist:
credential_id,cose_public_key,alg,sign_count,aaguid,transports,user_id.
- Decode
- Idempotency: Bind challenge to a request fingerprint. Duplicate submissions within TTL return the existing credential record without creating a second entry.
Authentication Pipeline
- Challenge issuance: RP generates a fresh challenge, stores it against the session; returns
PublicKeyCredentialRequestOptionswithallowCredentials(populated from stored credential IDs for the user) anduserVerification. - Client interaction: Browser calls
navigator.credentials.get(options)— or uses the Conditional Mediation API (mediation: "conditional") for autofill-driven passkey prompts. - Backend verification steps (§7.2):
- Decode and parse
clientDataJSON; verifytype === "webauthn.get". - Verify
challengeandorigin. - Look up
credentialIdin the store; retrieve stored public key andsignCount. - Parse
authData; verifyrpIdHash, UP flag, and UV flag. - Compute verification data:
authData || SHA-256(clientDataJSON). - Verify
signatureusing the stored COSE public key. - Enforce
signCountmonotonicity (§7.2 step 17). - Update stored
signCount; issue session token.
- Decode and parse
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
-
Cross-environment
rpIdmismatch. Staging environments that share a different subdomain from production will produceSecurityErrorat the client if therpIdis not explicitly matched to the effective domain. Mitigation: configurerpIdexplicitly in environment variables; never default towindow.location.hostname. -
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. -
Storing algorithm-less public keys. Without the COSE
algstored 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 persistalg(INTEGER) as a dedicated column at registration time. -
Ignoring
signCountfor synced passkeys. Synced passkeys (iCloud Keychain, Google Password Manager) may reportsignCount = 0on 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 receivedsignCountare 0, skip monotonicity enforcement but log the event; enforce strictly when storedsignCount > 0. -
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: setuserVerification: "required"and verify the UV flag is set inauthData.flagsbefore issuing the session. -
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.
-
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. -
Race conditions on concurrent multi-device registration. If two devices complete registration within the same request window, the
excludeCredentialscheck may fail to catch duplicates if the first credential is not yet committed to the primary replica. Mitigation: enforce a unique constraint oncredential_idat the database level and handle23505(PostgreSQL duplicate key) as a 409 conflict. -
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
INSERTpermissions only, or an external SIEM). -
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
- Designing Secure Registration Endpoints — RP endpoint architecture,
PublicKeyCredentialCreationOptionsconstruction, and rate-limiting patterns - Implementing Authentication Verification Logic — assertion verification pipeline,
signCountenforcement, and FIDO2 challenge generation - Credential Indexing and Database Schema Design — AAGUID storage, composite indexes, and multi-tenant credential isolation
- Handling Public Key Storage and Rotation — COSE key encoding, algorithm versioning, and user-driven key rotation flows
- Credential Revocation and Account Recovery — deterministic revocation state machine, orphaned credential cleanup, and recovery path design
- Server-Side Session Management with Passkeys — opaque vs JWT session tokens,
SameSitecookie hardening, and zero-trust session binding - Attestation Conveyance Policy Selection Guide — choosing
none,indirect,direct, orenterpriseand the verification each demands - Debugging and Observability for WebAuthn Servers — localising signature,
signCount, challenge, and origin failures with safe telemetry - WebAuthn FIDO2 Protocol Fundamentals — W3C spec hierarchy, attestation formats, and the cryptographic algorithms supported by WebAuthn
- Frontend UX and Conditional Mediation — the browser-side ceremony that produces the assertions this section verifies