Implementing Authentication Verification Logic
Authentication verification is the definitive cryptographic trust boundary in a passkey backend: every WebAuthn assertion arriving from a browser or native app must pass a multi-stage gauntlet before a session token is issued. This page details the complete server-side pipeline — from challenge anti-replay through cryptographic algorithms selection and signCount enforcement to secure session handoff. For the overall architecture this endpoint fits into, see Backend Verification and Secure Credential Storage.
Concept Definition and Spec Grounding
An assertion is the output of navigator.credentials.get(). The authenticator signs a payload composed of authenticatorData || SHA-256(clientDataJSON) using the private key created during registration; the server must reconstruct that exact payload and verify the signature with the stored public key credential.
W3C WebAuthn Level 2 §7.2 (“Verifying an Authentication Assertion”) defines the mandatory server checks in order. Key data structures:
| Field | Source | Size / Format |
|---|---|---|
clientDataJSON |
Browser-serialised JSON | UTF-8, base64url-encoded |
authenticatorData |
Authenticator-produced binary | ≥ 37 bytes |
rpIdHash |
Bytes 0–31 of authenticatorData |
32 bytes (SHA-256) |
| Flags byte | Byte 32 of authenticatorData |
Bitmask: UP=bit 0, UV=bit 2 |
signCount |
Bytes 33–36 of authenticatorData |
4-byte big-endian unsigned int |
signature |
Authenticator ECDSA/RSA/EdDSA output | DER-encoded |
credentialId |
Assertion id field |
16–1023 bytes, base64url |
The COSE algorithm identifier (stored at registration as an integer in the credential record) dictates which OpenSSL digest and key type to use. Never accept the algorithm the client claims in the assertion — always read it from the server-side credential record.
Architecture and Data Flow
The diagram below shows how the assertion moves through the verification pipeline, from browser to issued session.
Implementation Guide
Step 1 — Decode and parse the assertion payload
The browser sends clientDataJSON, authenticatorData, signature, and optionally userHandle as base64url strings. Decode each to a Buffer before any structural check. Never compare base64url strings directly — encoding variants (padding, character case) differ across platforms.
// assertion-decoder.ts
export interface RawAssertion {
credentialId: string; // base64url
clientDataJSON: string; // base64url
authenticatorData: string; // base64url
signature: string; // base64url
userHandle?: string; // base64url (optional)
}
export interface DecodedAssertion {
credentialIdBuf: Buffer;
clientDataBuf: Buffer;
clientData: { type: string; challenge: string; origin: string; crossOrigin?: boolean };
authenticatorDataBuf: Buffer;
signatureBuf: Buffer;
}
export function decodeAssertion(raw: RawAssertion): DecodedAssertion {
const clientDataBuf = Buffer.from(raw.clientDataJSON, 'base64url');
const clientData = JSON.parse(clientDataBuf.toString('utf8'));
return {
credentialIdBuf: Buffer.from(raw.credentialId, 'base64url'),
clientDataBuf,
clientData,
authenticatorDataBuf: Buffer.from(raw.authenticatorData, 'base64url'),
signatureBuf: Buffer.from(raw.signature, 'base64url'),
};
}
Spec reference: W3C WebAuthn §5.8.1 (clientDataJSON JSON text), §6.1 (base64url encoding conventions).
Step 2 — Validate the challenge and origin (anti-replay)
The challenge-response authentication flow relies on a server-generated nonce that must be consumed exactly once. See Best Practices for FIDO2 Challenge Generation for entropy requirements and encoding.
The comparison must use crypto.timingSafeEqual() — a naïve string comparison leaks which byte first mismatches, creating a timing side-channel an attacker can exploit iteratively.
// challenge-validator.ts
import { createClient } from 'redis';
import { timingSafeEqual } from 'crypto';
const redis = createClient({ url: process.env.REDIS_URL });
export async function validateAndConsumeChallenge(
sessionId: string,
submittedChallenge: string
): Promise<void> {
const stored = await redis.get(`webauthn:challenge:${sessionId}`);
// Delete first — prevent re-use even when validation subsequently fails
await redis.del(`webauthn:challenge:${sessionId}`);
if (!stored) throw new WebAuthnError('challenge_expired', 'Challenge not found or already consumed', 401);
const storedBuf = Buffer.from(stored, 'utf8');
const submittedBuf = Buffer.from(submittedChallenge, 'utf8');
if (storedBuf.length !== submittedBuf.length || !timingSafeEqual(storedBuf, submittedBuf)) {
throw new WebAuthnError('challenge_mismatch', 'Challenge comparison failed', 401);
}
}
export function validateClientDataFields(
clientData: { type: string; origin: string; crossOrigin?: boolean },
allowedOrigins: string[]
): void {
// §7.2 step 11: type must equal "webauthn.get"
if (clientData.type !== 'webauthn.get') {
throw new WebAuthnError('invalid_type', `Expected webauthn.get, got ${clientData.type}`, 400);
}
// §7.2 step 13: origin must be in the allowed set
if (!allowedOrigins.includes(clientData.origin)) {
throw new WebAuthnError('origin_mismatch', `Origin ${clientData.origin} not permitted`, 403);
}
// §7.2 step 14: crossOrigin must be false for top-level flows
if (clientData.crossOrigin === true) {
throw new WebAuthnError('cross_origin_denied', 'Cross-origin assertions not permitted', 403);
}
}
Spec reference: W3C WebAuthn §7.2 steps 11–14.
Step 3 — Verify the rpIdHash and authenticatorData flags
authenticatorData is a fixed-format binary structure. Bytes 0–31 contain the SHA-256 hash of the RP ID. Byte 32 is a flags bitmask.
Byte offset Field
──────────── ──────────────────────────────────────────
0–31 rpIdHash (SHA-256 of rp.id)
32 flags bit 0 = UP (user presence)
bit 2 = UV (user verification)
bit 6 = AT (attested credential)
bit 7 = ED (extensions)
33–36 signCount (4-byte big-endian uint32)
37+ attested credential data / extensions (optional)
// authn-data-parser.ts
import { createHash, timingSafeEqual } from 'crypto';
export interface AuthenticatorDataFields {
rpIdHash: Buffer;
flags: number;
signCount: number;
up: boolean; // user presence
uv: boolean; // user verification
}
export function parseAuthenticatorData(authData: Buffer): AuthenticatorDataFields {
if (authData.length < 37) {
throw new WebAuthnError('malformed_authn_data', 'authenticatorData too short', 400);
}
const flags = authData[32];
const signCount = authData.readUInt32BE(33);
return {
rpIdHash: authData.subarray(0, 32),
flags,
signCount,
up: (flags & 0x01) !== 0,
uv: (flags & 0x04) !== 0,
};
}
export function verifyRpIdHash(parsed: AuthenticatorDataFields, rpId: string): void {
const expected = createHash('sha256').update(rpId, 'utf8').digest();
// §7.2 step 16: rpIdHash must match
if (!timingSafeEqual(parsed.rpIdHash, expected)) {
throw new WebAuthnError('rpid_mismatch', 'rpIdHash does not match rp.id', 400);
}
}
export function verifyFlags(parsed: AuthenticatorDataFields, requireUV: boolean): void {
// §7.2 step 17: UP must always be set
if (!parsed.up) throw new WebAuthnError('up_flag_missing', 'User presence flag not set', 400);
// §7.2 step 18: UV must be set when policy requires it
if (requireUV && !parsed.uv) {
throw new WebAuthnError('uv_flag_missing', 'User verification required but UV flag not set', 400);
}
}
Spec reference: W3C WebAuthn §6.1 (authenticatorData), §7.2 steps 16–18.
Step 4 — Resolve the credential record
Once clientDataJSON and authenticatorData checks pass, resolve credentialId to the stored credential. The credential indexing and database schema page covers the required unique index. The query must be parameterised — never interpolate raw buffer values into SQL.
// credential-resolver.ts
import { db } from './database';
export interface CredentialRecord {
id: string;
userId: string;
publicKey: Buffer; // SPKI DER-encoded
alg: number; // COSE algorithm ID
signCount: number;
status: 'ACTIVE' | 'REVOKED' | 'SUSPENDED' | 'EXPIRED';
aaguid: string;
}
export async function resolveCredential(credentialIdBuf: Buffer): Promise<CredentialRecord> {
const record = await db.credentials.findFirst({
where: { credentialId: credentialIdBuf },
select: { id: true, userId: true, publicKey: true, alg: true, signCount: true, status: true, aaguid: true },
});
if (!record) {
throw new WebAuthnError('credential_not_found', 'Credential not registered', 404);
}
if (record.status !== 'ACTIVE') {
throw new WebAuthnError('credential_inactive', `Credential status: ${record.status}`, 403);
}
return record;
}
Spec reference: W3C WebAuthn §7.2 step 6 (credential lookup), §6.1.3 (credential revocation advisory).
Step 5 — Reconstruct and verify the cryptographic signature
The signed payload is authenticatorData || SHA-256(clientDataJSON). Map the COSE algorithm ID stored in the credential record to the appropriate OpenSSL digest name. For a deeper treatment of each algorithm path, see Handling WebAuthn Signature Verification in Node.js.
// signature-verifier.ts
import { createHash, verify as cryptoVerify } from 'crypto';
import { subtle } from 'crypto';
type CoseAlg = -7 | -8 | -257;
function coseAlgToNodeParams(alg: CoseAlg): { hashAlg: string; dsaEncoding?: 'der' | 'ieee-p1363' } {
switch (alg) {
case -7: return { hashAlg: 'SHA256', dsaEncoding: 'der' }; // ES256 — ECDSA P-256
case -257: return { hashAlg: 'RSA-SHA256' }; // RS256 — RSA PKCS#1
default: throw new WebAuthnError('unsupported_alg', `COSE alg ${alg} not supported`, 400);
}
}
export async function verifyAssertion(
publicKeySpki: Buffer,
coseAlg: number,
authenticatorData: Buffer,
clientDataBuf: Buffer,
signature: Buffer,
): Promise<boolean> {
const clientDataHash = createHash('sha256').update(clientDataBuf).digest();
const signedPayload = Buffer.concat([authenticatorData, clientDataHash]);
// Ed25519 (alg -8) — WebCrypto subtle only; Node crypto.verify does not support EdDSA
if (coseAlg === -8) {
const key = await subtle.importKey('spki', publicKeySpki, { name: 'Ed25519' }, false, ['verify']);
return subtle.verify({ name: 'Ed25519' }, key, signature, signedPayload);
}
const { hashAlg, dsaEncoding } = coseAlgToNodeParams(coseAlg as CoseAlg);
return cryptoVerify(
hashAlg,
signedPayload,
{ key: publicKeySpki, format: 'der', type: 'spki', ...(dsaEncoding ? { dsaEncoding } : {}) },
signature,
);
}
Spec reference: W3C WebAuthn §7.2 step 20 (signature verification), COSE RFC 8152 §8.
Step 6 — Enforce signCount monotonicity and update the record
signCount (bytes 33–36 of authenticatorData) is a counter incremented by the authenticator on each use. A non-monotonically-increasing counter is strong evidence of a cloned authenticator.
// sign-count-enforcer.ts
import { db } from './database';
export async function enforceAndUpdateSignCount(
credentialId: string,
storedCount: number,
newCount: number,
): Promise<void> {
// Authenticators MAY return 0 to indicate they don't implement counter (§7.2 step 23)
if (newCount === 0 && storedCount === 0) return; // counter not implemented — accept
if (newCount <= storedCount) {
// Flag as potentially cloned; do NOT update — preserve forensic evidence
await db.credentials.update({
where: { id: credentialId },
data: { status: 'SUSPENDED', suspendedReason: 'sign_count_regression' },
});
throw new WebAuthnError('sign_count_replay', 'signCount regression detected — possible cloned authenticator', 401);
}
// Atomic update: only update if the stored count still matches what we read
// (prevents TOCTOU race on concurrent requests with the same credential)
await db.$executeRaw`
UPDATE credentials
SET sign_count = ${newCount}
WHERE id = ${credentialId}
AND sign_count = ${storedCount}
`;
}
Spec reference: W3C WebAuthn §7.2 steps 22–23. FIDO Alliance Security Reference §5.4 (counter rollback policy).
Step 7 — Issue the session token
Only enter this function after all preceding checks return without throwing. Bind the token to both userId and credentialId, set the acr claim to the FIDO2 assurance level, and enforce secure cookie transport.
// session-issuer.ts
import jwt from 'jsonwebtoken';
import type { Response } from 'express';
export function issueSecureSession(
res: Response,
userId: string,
credentialId: string,
requireUV: boolean,
): { status: 'authenticated' } {
const acr = requireUV
? 'urn:fido:2.0:level2' // user-verified (AAL2)
: 'urn:fido:2.0:level1'; // user-presence only (AAL1)
const token = jwt.sign(
{
sub: userId,
cred: credentialId,
acr,
auth_time: Math.floor(Date.now() / 1000),
},
process.env.JWT_SECRET!,
{ algorithm: 'RS256', expiresIn: '1h' },
);
res.cookie('session_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 3_600_000,
});
return { status: 'authenticated' };
}
Compliance mapping: NIST SP 800-63B §4.2 (AAL2 assurance level requirements), HIPAA §164.312(d) (authentication audit logging).
Validation Checklist
Use this checklist for every authentication endpoint in code review and security testing:
Error Reference Table
| Error Code | HTTP Status | Trigger Condition | Diagnostic Command |
|---|---|---|---|
challenge_expired |
401 | Challenge TTL elapsed or already consumed | redis-cli GET "webauthn:challenge:<sid>" |
challenge_mismatch |
401 | Submitted nonce does not match stored value | Check base64url padding; compare raw buffer lengths |
invalid_type |
400 | clientDataJSON.type is not webauthn.get |
Dump decoded clientDataJSON; check registration vs authentication path confusion |
origin_mismatch |
403 | clientDataJSON.origin not in RP_CONFIG.origins |
Log clientData.origin; verify browser base URL matches RP config |
rpid_mismatch |
400 | rpIdHash does not equal SHA-256(rp.id) |
Run echo -n "yourdomain.com" | openssl dgst -sha256 -binary | xxd and compare |
up_flag_missing |
400 | Bit 0 of flags byte is 0 |
Authenticator did not collect user presence; check CTAP2 response from authenticator logs |
uv_flag_missing |
400 | UV required but bit 2 of flags byte is 0 |
Verify userVerification: 'required' in PublicKeyCredentialRequestOptions |
credential_not_found |
404 | credentialId not in the database |
Check that the credential was registered; verify base64url decoding is consistent |
credential_inactive |
403 | Credential status is REVOKED, SUSPENDED, or EXPIRED |
Query SELECT status FROM credentials WHERE credential_id = ? |
unsupported_alg |
400 | COSE alg ID in record is not -7, -8, or -257 |
Check what was stored at registration; MDS3 metadata may list unsupported alg |
sign_count_replay |
401 | newCount <= storedCount — possible cloned authenticator |
Query sign_count history; initiate account recovery flow |
Platform and Library Notes
@simplewebauthn/server (Node.js)
verifyAuthenticationResponse() handles steps 1–6 automatically. Pass requireUserVerification: true to enforce the UV flag, and set advancedFIDOConfig.userVerification when targeting FIDO2 MDS3 policies. The library raises typed errors with verified === false rather than throwing.
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response: authResponse,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://auth.example.com',
expectedRPID: 'auth.example.com',
credential: { id, publicKey, counter: storedSignCount, transports },
requireUserVerification: true,
});
if (!verified) throw new WebAuthnError('assertion_failed', 'Verification returned false', 401);
fido2-lib (Node.js)
Call fido2lib.assertionResult() with the full assertion object. Pass userHandle explicitly — fido2-lib does not look it up from the credential store automatically.
py_webauthn (Python)
verify_authentication_response() accepts require_user_verification=True. Ensure expected_challenge is the raw bytes, not the base64-encoded string — a common source of ChallengeException.
from webauthn import verify_authentication_response
from webauthn.helpers.structs import UserVerificationRequirement
verification = verify_authentication_response(
credential=credential,
expected_challenge=challenge_bytes, # raw bytes, not base64
expected_rp_id='auth.example.com',
expected_origin='https://auth.example.com',
credential_public_key=stored_public_key_bytes,
credential_current_sign_count=stored_sign_count,
require_user_verification=True,
)
WebAuthn4J (Java / Spring)
WebAuthnManager.verify() enforces sign count and origin by default. Configure DCApprover when MDS3 attestation metadata checks are needed during authentication (enterprise AAGUID allow-listing).
iOS Safari / macOS Touch ID
Apple’s platform authenticator always returns signCount = 0 for iCloud Keychain–synced credentials because sign counts cannot be maintained across devices. Implement the 0/0 exemption in enforceAndUpdateSignCount() and rely on device-binding checks (AAGUID + transport) for anomaly detection instead.
Android FIDO2 API
Google Play Services may set UV = 0 when the device has no biometric enrolled but screen lock is present; it interprets screen-lock unlock as user presence only. If your policy requires UV = 1, return a 400 error with a human-readable message instructing the user to enrol biometrics.
Windows Hello (CTAP2)
Windows Hello’s platform authenticator increments signCount correctly for hardware-bound keys. Passkeys synced through Microsoft’s credential cloud provider return signCount = 0, matching Apple’s behaviour.
Pitfalls and Security Hardening
1. Comparing clientDataJSON as a string instead of parsing JSON.
Chrome, Safari, and Firefox may produce different whitespace or key ordering in clientDataJSON. Always decode from base64url and JSON.parse() before inspecting any field; never compare the raw string to a stored value.
2. Not deleting the challenge before returning the comparison result. If you delete the challenge only on success, a failed attempt leaves the nonce in cache. An attacker can replay the same assertion payload until the TTL expires. Always delete the challenge as the very first action inside the validation function, before any comparison.
3. Using the algorithm the client asserts, not the stored COSE alg.
A client that sends a forged alg field in the assertion body can force the server to verify the signature under a weaker algorithm. The alg must come from the credential record written at registration — never from the authentication response.
4. Missing the atomic guard on signCount update.
Two concurrent requests with the same credential and the same signCount can both pass a > storedCount check if the database read happens before either write completes. Use a WHERE sign_count = oldValue predicate in the UPDATE statement; a row-count of 0 signals a concurrent update and should trigger a 409 / retry.
5. Accepting UV = 0 silently when policy requires verification.
If your application advertises userVerification: 'required' in the options object but the server does not enforce the UV flag, an authenticator that downgrades to user-presence-only can still authenticate. Explicitly check bit 2 of the flags byte and reject with a clear error when your policy mandates it.
6. Not logging the AAGUID on each authentication.
AAGUID identifies the authenticator model and maps to MDS3 metadata. Logging it per assertion lets you detect when an account suddenly authenticates from an authenticator class it has never used — a useful signal for anomalous access detection, especially after a signCount regression.
Related
- Best Practices for FIDO2 Challenge Generation — entropy requirements, TTL strategy, and Redis key design for single-use nonces
- Handling WebAuthn Signature Verification in Node.js — deep dive on ECDSA DER signature parsing and Ed25519 via WebCrypto
- Credential Indexing and Database Schema Design — credentialId indexing, composite keys, and constant-time lookup patterns
- Handling Public Key Storage and Rotation — SPKI serialisation, key rotation strategies, and AAGUID-driven metadata policy
- Server-Side Session Management with Passkeys — JWT claims, cookie lifecycle, and token refresh after passkey re-authentication
- Backend Verification and Secure Credential Storage — parent section covering the full registration and authentication backend architecture