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.

WebAuthn Assertion Verification Pipeline Sequence diagram showing the seven verification steps the server performs after receiving an assertion from the browser: decode payload, validate challenge, verify rpIdHash and flags, look up credential, verify signature, enforce signCount, issue session. Browser / Client Server Verification Pipeline 1. Decode Payload base64url → Buffer 2. Challenge + Origin timingSafeEqual · type=webauthn.get 3. rpIdHash + Flags SHA-256(rp.id) · UP=1 · UV policy 4. Credential Lookup credentialId → public key + alg 5. Signature Verify authData ‖ SHA-256(cdj) · COSE alg 6. signCount Enforce newCount > storedCount (atomic write) 7. Issue Session JWT · HttpOnly cookie · audit log HTTPS POST assertion payload Set-Cookie / 200 session_token Any step fails HTTP 401 credential store W3C WebAuthn Level 2 §7.2 — steps enforced in order

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