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_SIGNATUREError: Invalid signature
Root Causes
- Signature or
clientDataJSONdecoded using standard Base64 instead of Base64URL. - Public key stored in raw COSE format but passed directly to
crypto.createVerify(). - Hash algorithm mismatch between credential
algparameter and Node.jscreateVerify()input.
Diagnostic Commands & Reproduction Steps
- 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
signatureandclientDataJSONusingBuffer.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_HASHError: Challenge mismatchError: Invalid assertion type
Root Causes
- Stored challenge expired or was consumed by a previous request.
- Client sends
webauthn.createtype during authentication flow. - Origin or RP ID mismatch between
clientDataJSONand server allowlist. - Non-constant-time string comparison leaking timing data.
Diagnostic Commands & Reproduction Steps
- Inspect
clientDataJSONstructure:
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
clientDataJSONand asserttype === 'webauthn.get'. - Compare stored challenge with incoming challenge using
crypto.timingSafeEqual(). - Validate
clientDataJSON.originagainst exact RP origin string. - Reject assertions where
challengeis 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 failedError: User verification required
Root Causes
- Authenticator cloned or credential exported to insecure device.
- Verification logic bypasses
userVerifiedflag for high-assurance flows. - Raw signatures or private key material accidentally logged in debug output.
Diagnostic Commands & Reproduction Steps
- Audit counter drift: Query DB for
signCounthistory and verify monotonic increase:
SELECT id, counter FROM credentials WHERE id = ? ORDER BY updated_at DESC;
- Verify UV flag: Decode
authenticatorDatabyte 37:flags & 0x04 !== 0. Usenode -e "console.log((authData[37] & 0x04) !== 0)"to confirm user verification. - Test log sanitization: Trigger a failed assertion and grep logs for
signatureorclientDataJSONto ensure PII/crypto material is stripped.
Step-by-Step Fixes
- Enforce
requireUserVerification: truefor sensitive authentication paths. - Compare incoming
signCountagainst storedcounter; reject ifincoming <= stored. - Update database
counteratomically only after successful verification. - Sanitize logs to emit
credential_id,error_type, andtimestamponly; stripclientDataJSONandsignature.
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');
}