Handling WebAuthn Signature Verification in Node.js

When assertion validation fails in production, the root cause typically traces to encoding mismatches or cryptographic boundary violations. Properly diagnosing these failures requires aligning Node.js crypto primitives with FIDO2 spec requirements within your broader Backend Verification & Secure Credential Storage architecture. This guide provides exact reproduction steps, diagnostic commands, and secure remediation patterns for handling WebAuthn signature verification in Node.js.

Isolating Node.js Crypto Verification Failures

Signature verification in Node.js relies on OpenSSL bindings. When crypto.verify() throws, it indicates a structural mismatch between the authenticator’s output and the server’s expected input format. Debugging requires strict Base64URL normalization and COSE-to-SPKI conversion before hash validation.

Exact Error Codes

  • ERR_CRYPTO_INVALID_SIGNATURE
  • Error: Invalid signature

Root Causes

  • Signature or clientDataJSON decoded using standard Base64 instead of Base64URL.
  • Public key stored in raw COSE format but passed directly to crypto.createVerify().
  • Hash algorithm mismatch between credential alg parameter and Node.js createVerify() input.

Diagnostic Commands & Reproduction Steps

  1. Verify Base64URL decoding length:
node -e "console.log(Buffer.from('raw_sig_b64url', 'base64url').length)"

Expected: 64 bytes for P-256 (ES256), 72 bytes for Ed25519. Mismatch indicates padding corruption or truncation. 2. Validate SPKI structure:

echo "base64_spki_key" | base64 -d | openssl asn1parse -inform DER

Expected output must contain SubjectPublicKeyInfo with correct OID (1.2.840.10045.2.1 for EC). 3. Reproduce hash boundary:

node -e "const crypto = require('crypto'); console.log(crypto.createHash('sha256').update(Buffer.from('raw_client_data', 'base64url')).digest('hex'))"

Compare against the authenticator’s signed payload to isolate truncation or encoding drift.

Step-by-Step Fixes

  • Normalize incoming signature and clientDataJSON using Buffer.from(val, 'base64url').
  • Extract raw COSE public key from credential record, decode to JWK, and convert to SPKI/PEM.
  • Ensure createVerify('SHA-256') matches the credential’s registered algorithm (e.g., ES256).
  • Validate signature byte length against expected curve before invoking verify().

Secure Code Patch

const crypto = require('crypto');

// 1. Normalize Base64URL inputs (strips padding, handles URL-safe chars)
const sigBuffer       = Buffer.from(rawSignature,          'base64url');
const clientDataBuffer = Buffer.from(rawClientData,        'base64url');
const authDataBuffer  = Buffer.from(rawAuthenticatorData,  'base64url');

// 2. Build the signed payload: authenticatorData ‖ SHA-256(clientDataJSON)
//    Per WebAuthn spec §7.2 — the authenticator signs this exact concatenation
const clientDataHash = crypto.createHash('sha256').update(clientDataBuffer).digest();
const signedPayload  = Buffer.concat([authDataBuffer, clientDataHash]);

// 3. Initialize verifier with correct algorithm (ES256 → SHA-256, RS256 → RSA-SHA256)
const verifier = crypto.createVerify('SHA-256');
verifier.update(signedPayload);

// 4. Execute verification using SPKI-formatted public key
const isValid = verifier.verify(spkiPublicKey, sigBuffer);
if (!isValid) throw new Error('ERR_CRYPTO_INVALID_SIGNATURE');

Resolving ClientDataJSON & Challenge Mismatches

Invalid assertion types or challenge drift trigger immediate verification rejection. The clientDataJSON payload must be parsed, validated against the stored challenge, and checked for exact origin binding before any cryptographic operation executes. This validation step is a critical component of Implementing Authentication Verification Logic and prevents replay attacks.

Exact Error Codes

  • WebAuthnError: INVALID_CLIENT_DATA_HASH
  • Error: Challenge mismatch
  • Error: Invalid assertion type

Root Causes

  • Stored challenge expired or was consumed by a previous request.
  • Client sends webauthn.create type during authentication flow.
  • Origin or RP ID mismatch between clientDataJSON and server allowlist.
  • Non-constant-time string comparison leaking timing data.

Diagnostic Commands & Reproduction Steps

  1. Inspect clientDataJSON structure:
node -e "console.log(JSON.parse(Buffer.from(rawClientData, 'base64url').toString('utf8')))"

Verify type, challenge, and origin keys exist and are correctly typed. 2. Validate challenge format: Ensure the stored challenge is a Base64URL string without padding (=). Run node -e "console.log(Buffer.from(storedChallenge, 'base64url').length)" to confirm byte alignment. 3. Simulate timing leak: Replace === with crypto.timingSafeEqual() and benchmark execution variance to confirm constant-time behavior.

Step-by-Step Fixes

  • Parse clientDataJSON and assert type === 'webauthn.get'.
  • Compare stored challenge with incoming challenge using crypto.timingSafeEqual().
  • Validate clientDataJSON.origin against exact RP origin string.
  • Reject assertions where challenge is missing or structurally invalid before hash computation.

Secure Code Patch

const clientData = JSON.parse(Buffer.from(rawClientData, 'base64url').toString('utf8'));

// 1. Enforce correct assertion type
if (clientData.type !== 'webauthn.get') {
 throw new Error('Invalid assertion type');
}

// 2. Timing-safe challenge comparison (prevents oracle attacks)
const expectedChallengeBuffer = Buffer.from(storedChallenge, 'utf8');
const incomingChallengeBuffer = Buffer.from(clientData.challenge, 'base64url');

if (expectedChallengeBuffer.length !== incomingChallengeBuffer.length ||
 !crypto.timingSafeEqual(expectedChallengeBuffer, incomingChallengeBuffer)) {
 throw new Error('Challenge mismatch or replay attempt');
}

Production-Ready Verification Patch & Compliance Logging

Replace manual crypto calls with spec-compliant assertion verification to eliminate edge-case encoding bugs. Ensure verification counters increment monotonically and error telemetry excludes raw cryptographic material. Structured logging must align with SOC 2 and FIDO2 Level 2 audit requirements while maintaining operational visibility.

Exact Error Codes

  • Error: Signature counter validation failed
  • Error: User verification required

Root Causes

  • Authenticator cloned or credential exported to insecure device.
  • Verification logic bypasses userVerified flag for high-assurance flows.
  • Raw signatures or private key material accidentally logged in debug output.

Diagnostic Commands & Reproduction Steps

  1. Audit counter drift: Query DB for signCount history and verify monotonic increase:
SELECT id, counter FROM credentials WHERE id = ? ORDER BY updated_at DESC;
  1. Verify UV flag: Decode authenticatorData byte 37: flags & 0x04 !== 0. Use node -e "console.log((authData[37] & 0x04) !== 0)" to confirm user verification.
  2. Test log sanitization: Trigger a failed assertion and grep logs for signature or clientDataJSON to ensure PII/crypto material is stripped.

Step-by-Step Fixes

  • Enforce requireUserVerification: true for sensitive authentication paths.
  • Compare incoming signCount against stored counter; reject if incoming <= stored.
  • Update database counter atomically only after successful verification.
  • Sanitize logs to emit credential_id, error_type, and timestamp only; strip clientDataJSON and signature.

Secure Code Patch

// 1. Monotonic counter validation (prevents cloned authenticator replay)
if (verification.counter <= credential.counter) {
 logger.warn('Potential cloned authenticator detected', { credentialId: credential.id });
 throw new Error('Signature counter validation failed');
}

// 2. Atomic counter update post-verification
await db.credentials.update({ id: credential.id }, { counter: verification.counter });

// 3. Secure failure logging (compliance-aligned, zero crypto material)
if (!verification.verified) {
 logger.info('Assertion verification failed', {
 credentialId: credential.id,
 errorType: verification.error?.code || 'UNKNOWN',
 timestamp: new Date().toISOString()
 });
 throw new Error('Authentication failed');
}