Best Practices for FIDO2 Challenge Generation

FIDO2 challenge generation is the cryptographic foundation of every challenge-response authentication flow. When entropy is weak, encoding is wrong, or lifecycle management is absent, an attacker can forge or replay assertions without possessing the private key. This page isolates the four most common server-side failure modes — bad entropy, encoding drift, missing TTL enforcement, and unsafe comparison — and provides spec-aligned remediation for each. For the broader server-side architecture this fits into, see Implementing Authentication Verification Logic.


Challenge lifecycle: how the pieces fit together

The diagram below shows the complete server-side challenge path from issuance through atomic deletion. Every numbered step maps to a section lower on this page.

FIDO2 challenge lifecycle: issuance, storage, transport, and verification Five sequential stages — CSPRNG generation, base64url encoding, atomic Redis storage, client transport, and timing-safe comparison — joined by labelled arrows. CSPRNG 32 bytes entropy base64url 43 chars no padding Redis SET NX · EX 120 bound to session Client signs challenge in clientDataJSON GETDEL timingSafe Equal ① entropy ② encode ③ store ④ transport ⑤ verify weak PRNG → INSUFFICIENT_ENTROPY padding → PADDING_MISMATCH no NX → RACE_CONDITION double-encode → CHALLENGE_MISMATCH string cmp → timing leak FIDO2 Challenge Lifecycle WebAuthn Level 2 §7.1 / §7.2 — server-side path

Exact error signatures and spec constraints

The table below maps the errors you will see in logs or browser DevTools to their root causes. Scan your error code here first, then jump to the matching root cause section.

Error / Symptom HTTP Status Spec Reference Trigger Condition
INVALID_CHALLENGE_FORMAT 400 WebAuthn L2 §5.8.1 Standard Base64 (+, /, =) instead of base64url
INSUFFICIENT_ENTROPY 400 WebAuthn L2 §7.1 step 1 Non-CSPRNG source (Math.random, rand())
PADDING_MISMATCH 400 Base64url RFC 4648 §5 Trailing = characters retained during serialisation
CHALLENGE_EXPIRED 401 NIST SP 800-63B §5.1.3 No server-side TTL tracking; challenge accepted after timeout
CHALLENGE_ALREADY_USED 401 WebAuthn L2 §7.2 step 17 Challenge not deleted after first assertion validation
RACE_CONDITION_DETECTED 409 Challenge overwritten by concurrent request (missing NX)
CHALLENGE_MISMATCH 401 WebAuthn L2 §7.2 step 11 Double-encoding during JSON serialisation or HTTP transport
INVALID_SIGNATURE 401 WebAuthn L2 §7.2 step 20 Incorrect base64url decoding before signature buffer construction

Root cause analysis

Root cause 1 — Non-cryptographic PRNG

Math.random() in JavaScript and rand() in C/PHP produce pseudo-random output seeded from a predictable state. An attacker who observes several challenges can reconstruct the PRNG state and predict future challenges, enabling forged assertions before the authenticator is involved. The WebAuthn Level 2 specification (§7.1 step 1) mandates that the challenge be “a cryptographically random buffer”.

The diagnostic signal is INSUFFICIENT_ENTROPY or a repeating byte pattern visible when you log challenge hex values across requests.

Root cause 2 — Standard Base64 encoding

Buffer.from(bytes).toString('base64') in Node.js appends = padding and uses + and / characters that are invalid in a URL context. Many browsers silently coerce or reject these, producing INVALID_CHALLENGE_FORMAT or PADDING_MISMATCH on the server-side comparison step.

Base64url-encoded 32 bytes always yields exactly 43 characters: ⌈(32 × 4) / 3⌉ = 43 (no padding block needed). If your challenge is 44 characters, a = pad byte has slipped through.

Root cause 3 — Missing atomic TTL and single-use enforcement

Without a server-side TTL, a captured challenge token remains valid indefinitely — a replay window that bypasses the authenticator entirely. Without single-use enforcement (atomically deleting the challenge on first consumption), a race condition allows two concurrent assertion requests to validate against the same challenge, undermining the binding between assertion and authentication event.

The signCount check in authenticatorData is a separate anti-replay layer, but it does not replace challenge lifecycle enforcement.

Root cause 4 — String equality and double-encoding in transport

JSON serialisation can silently double-encode a base64url string if a middleware layer re-stringifies an already-serialised value. The symptom is CHALLENGE_MISMATCH even when your encoding is correct. A second failure mode is using string equality (===) for comparison instead of crypto.timingSafeEqual, which creates a timing side-channel that leaks how many bytes match before the first mismatch.


Step-by-step resolution

Step 1 — Replace the random source with a CSPRNG

import { randomBytes } from 'node:crypto';

export function generateChallenge(): string {
  const raw = randomBytes(32);          // 256-bit OS-backed CSPRNG
  const encoded = raw.toString('base64url'); // RFC 4648 §5: no padding, URL-safe alphabet
  // base64url of 32 bytes = exactly 43 characters; guard against truncation
  if (encoded.length < 43) {
    throw new Error('CHALLENGE_LENGTH_VIOLATION: expected 43 chars, got ' + encoded.length);
  }
  return encoded;
}

Verify the encoding is clean before you wire it into storage:

node -e "
const { randomBytes } = require('node:crypto');
const c = randomBytes(32).toString('base64url');
const ok = /^[A-Za-z0-9_-]{43}$/.test(c);
console.log(ok ? 'PASS' : 'FAIL', c.length, 'chars:', c);
"

Step 2 — Store atomically with TTL and session binding

import { createClient } from 'redis';
import { generateChallenge } from './challenge';
import { createHash } from 'node:crypto';

const redis = createClient();
await redis.connect();

const CHALLENGE_TTL_SECONDS = 120;

export async function issueChallenge(sessionId: string, rpId: string): Promise<string> {
  const challenge = generateChallenge();
  // Key is scoped to both the session and the RP so challenges cannot cross tenant boundaries
  const key = `fido2:ch:${rpId}:${createHash('sha256').update(sessionId).digest('hex').slice(0, 16)}`;

  // NX — set only if the key does not exist; prevents overwriting an active challenge
  const ok = await redis.set(key, challenge, { EX: CHALLENGE_TTL_SECONDS, NX: true });
  if (!ok) {
    throw new Error('Active challenge already pending for this session — retry after expiry');
  }
  return challenge;
}

Inspect the stored key and remaining TTL in a staging environment:

redis-cli GET  "fido2:ch:example.com:<hashed-session>"
redis-cli TTL  "fido2:ch:example.com:<hashed-session>"

Step 3 — Consume atomically with GETDEL

export async function consumeChallenge(
  sessionId: string,
  rpId: string
): Promise<string | null> {
  const key = `fido2:ch:${rpId}:${createHash('sha256').update(sessionId).digest('hex').slice(0, 16)}`;
  // GETDEL is atomic: the key is deleted whether or not comparison succeeds downstream
  const challenge = await redis.getDel(key);
  return challenge; // null → expired or already consumed
}

Step 4 — Compare with timing-safe equality

import { timingSafeEqual } from 'node:crypto';

export async function validateChallenge(
  sessionId: string,
  rpId: string,
  clientChallengeB64url: string
): Promise<void> {
  const stored = await consumeChallenge(sessionId, rpId);
  if (!stored) {
    throw new Error('CHALLENGE_EXPIRED_OR_CONSUMED');
  }

  const storedBuf = Buffer.from(stored,                 'base64url');
  const clientBuf = Buffer.from(clientChallengeB64url,  'base64url');

  // Length check must come first; timingSafeEqual throws if buffers differ in length
  if (storedBuf.length !== clientBuf.length) {
    throw new Error('CHALLENGE_MISMATCH');
  }
  if (!timingSafeEqual(storedBuf, clientBuf)) {
    throw new Error('CHALLENGE_MISMATCH');
  }
}

Step 5 — Emit a compliance-grade audit log

Log the SHA-256 of the challenge (never the raw value) so you have a forensic trail without expanding the replay surface if log storage is compromised:

import { createHash } from 'node:crypto';

logger.info({
  event:                'fido2_challenge_generated',
  challenge_sha256:     createHash('sha256').update(challenge).digest('hex'),
  entropy_source:       'node:crypto.randomBytes',
  ttl_seconds:          120,
  session_id_hash:      createHash('sha256').update(sessionId).digest('hex'),
  rp_id:                rpId,
  timestamp_ms:         Date.now(),
  compliance_framework: 'NIST-800-63B-AAL2',
});

Verification and testing

Automated unit test assertions

import { describe, it, expect } from 'vitest';
import { generateChallenge } from './challenge';

describe('generateChallenge', () => {
  it('produces a valid base64url string of 43 characters', () => {
    const c = generateChallenge();
    expect(c).toMatch(/^[A-Za-z0-9_-]{43}$/);
  });

  it('produces unique values across 1000 calls', () => {
    const samples = new Set(Array.from({ length: 1000 }, generateChallenge));
    expect(samples.size).toBe(1000);
  });

  it('contains no padding characters', () => {
    expect(generateChallenge()).not.toContain('=');
  });
});

FIPS 140-3 compliance check

# Confirm the OpenSSL provider used by Node.js supports FIPS-approved algorithms
openssl version -a | grep -i fips
node -e "const c = require('node:crypto'); console.log(c.getFips());" # 1 = FIPS mode active

End-to-end smoke test with curl

# 1. Fetch a challenge from your authentication options endpoint
CHALLENGE=$(curl -s -X POST https://auth.example.com/webauthn/auth/start \
  -H "Content-Type: application/json" \
  -d '{"username":"testuser"}' | jq -r '.challenge')

echo "Challenge: $CHALLENGE"
echo "Length:    ${"#CHALLENGE"}"              # expect 43
echo "$CHALLENGE" | grep -P '^[A-Za-z0-9_-]+$' && echo "BASE64URL VALID" || echo "INVALID"

# 2. Confirm the challenge is gone from Redis immediately after consumption
redis-cli GET "fido2:ch:example.com:$(echo -n 'testuser' | sha256sum | cut -c1-16)"
# Expected: (nil)

Race condition test

# Fire five concurrent assertion-start requests; each must receive a distinct challenge
for i in {1..5}; do
  curl -s -X POST https://auth.example.com/webauthn/auth/start \
    -H "Content-Type: application/json" \
    -d '{"username":"testuser"}' | jq -r '.challenge' &
done
wait
# All five output lines should be unique; if any duplicate appears, NX is not enforced

Pitfalls specific to FIDO2 challenge generation

Pitfall 1 — Logging the raw challenge value. Storing the challenge in plaintext logs widens the replay surface: anyone with log read access can extract a valid challenge before it expires. Always hash with SHA-256 before writing to any persistent store.

Pitfall 2 — Reusing a challenge key across tabs or devices. If your session key is derived only from the user’s account ID (not the browser session ID), a second tab opening an authentication prompt overwrites the first tab’s challenge, causing CHALLENGE_EXPIRED or RACE_CONDITION_DETECTED errors in multi-tab flows. Key the Redis entry on a per-session token generated at session creation and rotated on each authentication ceremony.

Pitfall 3 — Accepting expired challenges because the server clock is wrong. Redis TTL relies on the Redis server’s system clock. If your application server and Redis server have clock skew greater than a few seconds, challenges may appear expired from the application’s perspective or survive longer than intended. Run NTP synchronisation on all nodes in the authentication path and confirm clocks agree within 1 second.


Related