Handling WebAuthn Signature Verification in Node.js
When assertion validation fails in production, the root cause almost always traces to one of three boundary violations: encoding mismatches (standard Base64 where base64url is required), a COSE key passed raw to crypto.verify() without SPKI/DER conversion, or an incorrect OpenSSL digest name. This page isolates each failure mode, provides diagnostic commands, and gives production-ready code patches. It is a companion to Implementing Authentication Verification Logic, which covers the full assertion pipeline within Backend Verification and Secure Credential Storage.
Error Reference Table
Scan-match your error against this table before reading further.
| Symptom | Error / Return Value | Most Likely Cause |
|---|---|---|
crypto.verify() throws |
ERR_OSSL_EVP_UNSUPPORTED |
Algorithm name is 'ES256' — must be 'SHA256' |
crypto.verify() throws |
ERR_CRYPTO_INVALID_KEYTYPE |
Public key is raw COSE bytes, not SPKI/DER |
crypto.verify() returns false |
— | base64url → Buffer decoding wrong, or signed payload constructed incorrectly |
crypto.verify() throws |
ERR_CRYPTO_INVALID_SIGNATURE |
Signature bytes corrupted, wrong encoding, or IEEE P1363 vs DER format mismatch |
| Challenge comparison fails | custom error | Standard Base64 (with +, /, =) used instead of base64url |
clientDataJSON.type wrong |
custom error | Registration token reused in an authentication assertion |
Signed-Payload Architecture
Understanding what the authenticator signs is the prerequisite for every fix below. The WebAuthn spec (§7.2 step 20) defines the signed payload as:
authenticatorData || SHA-256(clientDataJSON)
The authenticator never signs clientDataJSON directly — it signs its SHA-256 hash, concatenated after authenticatorData. Getting this construction wrong (for example, hashing authenticatorData instead, or forgetting to hash clientDataJSON) always produces a correct-looking call to crypto.verify() that returns false.
The diagram below shows the full data path from authenticator output to Node.js verification.
Root Cause Analysis
Failure mode 1: wrong OpenSSL digest name for ECDSA
crypto.verify() requires an OpenSSL message-digest name as its first argument. COSE algorithm identifiers (-7 for ES256, -8 for EdDSA, -257 for RS256) are defined in Cryptographic Algorithms Supported by WebAuthn — they are not OpenSSL names. Passing 'ES256' triggers ERR_OSSL_EVP_UNSUPPORTED immediately. The correct mapping is:
| COSE alg ID | COSE name | crypto.verify() first argument |
|---|---|---|
-7 |
ES256 | 'SHA256' |
-35 |
ES384 | 'SHA384' |
-36 |
ES512 | 'SHA512' |
-257 |
RS256 | 'RSA-SHA256' |
-8 |
EdDSA | use crypto.subtle with { name: 'Ed25519' } |
Failure mode 2: COSE public key passed without SPKI conversion
Authenticators return public keys in CBOR-encoded COSE format (a COSE_Key map). Node.js crypto.verify() does not understand COSE; it requires a SubjectPublicKeyInfo (SPKI) DER buffer or a KeyObject. Passing raw COSE bytes produces ERR_CRYPTO_INVALID_KEYTYPE. The stored SPKI/DER representation from handling public key storage and rotation is what you should persist and retrieve at verification time.
Failure mode 3: base64url vs standard Base64
The WebAuthn API delivers signature, authenticatorData, and clientDataJSON as base64url strings (RFC 4648 §5): they use - and _ in place of + and /, with no = padding. Decoding these with Buffer.from(val, 'base64') silently corrupts byte sequences that contain - or _, producing a payload hash mismatch that causes crypto.verify() to return false with no thrown error.
Failure mode 4: ECDSA signature encoding mismatch (P1363 vs DER)
P-256 ECDSA produces a signature of two 32-byte integers, r and s. Some authenticators return these in IEEE P1363 format (64 bytes, raw concatenation of r || s). Node.js crypto.verify() with { dsaEncoding: 'der' } (the default for EC keys) expects DER-encoded ASN.1 sequences. Passing P1363 bytes without converting produces ERR_CRYPTO_INVALID_SIGNATURE or false.
Step-by-Step Resolution
Step 1 — Normalize all base64url inputs
function decodeBase64url(value: string): Buffer {
return Buffer.from(value, 'base64url');
}
const rawAuthenticatorData = decodeBase64url(assertion.response.authenticatorData);
const rawClientDataJSON = decodeBase64url(assertion.response.clientDataJSON);
const rawSignature = decodeBase64url(assertion.response.signature);
Run a quick sanity check:
node -e "
const buf = Buffer.from(process.argv[1], 'base64url');
console.log('bytes:', buf.length);
" "$SIGNATURE_B64URL"
# ES256 DER signature: variable length, typically 70-72 bytes
# ES256 P1363 signature: exactly 64 bytes
Step 2 — Build the signed payload per WebAuthn spec §7.2
import { createHash } from 'crypto';
const clientDataHash = createHash('sha256').update(rawClientDataJSON).digest();
const signedPayload = Buffer.concat([rawAuthenticatorData, clientDataHash]);
This step is the most common source of silent failures. Verify the hash output:
node -e "
const crypto = require('crypto');
const raw = Buffer.from(process.argv[1], 'base64url');
console.log('clientDataJSON SHA-256:', crypto.createHash('sha256').update(raw).digest('hex'));
" "$CLIENT_DATA_B64URL"
Step 3 — Convert COSE key to SPKI/DER (if not already stored as SPKI)
If you stored the raw COSE key at registration time, convert it to SPKI before calling crypto.verify(). For a P-256 key (COSE alg -7), the SPKI DER prefix is a fixed 26-byte sequence followed by an uncompressed point byte (0x04) and the 64-byte x || y coordinates:
import * as cbor from 'cbor';
import { createPublicKey } from 'crypto';
async function coseToSpkiBuffer(coseKeyBuffer: Buffer): Promise<Buffer> {
const coseMap = await cbor.decodeFirst(coseKeyBuffer) as Map<number, Buffer | number>;
const alg = coseMap.get(3) as number; // COSE key parameter 3 = alg
const kty = coseMap.get(1) as number; // COSE key type: 2 = EC2
if (kty === 2 && alg === -7) {
// EC2 key, P-256 curve (crv = 1)
const x = coseMap.get(-2) as Buffer; // x coordinate, 32 bytes
const y = coseMap.get(-3) as Buffer; // y coordinate, 32 bytes
// SubjectPublicKeyInfo DER header for P-256 (id-ecPublicKey OID + prime256v1 OID)
const spkiHeader = Buffer.from(
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
'hex'
);
const uncompressedPoint = Buffer.concat([Buffer.from([0x04]), x, y]);
return Buffer.concat([spkiHeader, uncompressedPoint]);
}
throw new Error(`Unsupported COSE key type/alg: kty=${kty} alg=${alg}`);
}
Validate the result with OpenSSL:
echo "<base64_of_spki_bytes>" | base64 -d | openssl asn1parse -inform DER
# Expected: OID 1.2.840.10045.2.1 (EC), OID 1.2.840.10045.3.1.7 (prime256v1)
Step 4 — Call crypto.verify() with the correct digest name
import { verify, createPublicKey } from 'crypto';
function verifyWebAuthnAssertion(
spkiPublicKeyBuffer: Buffer,
coseAlg: number,
signedPayload: Buffer,
rawSignature: Buffer
): boolean {
// Map COSE alg ID to OpenSSL digest name
const digestName: Record<number, string> = {
[-7]: 'SHA256', // ES256 — ECDSA P-256 + SHA-256
[-35]: 'SHA384', // ES384 — ECDSA P-384 + SHA-384
[-36]: 'SHA512', // ES512 — ECDSA P-521 + SHA-512
[-257]: 'RSA-SHA256', // RS256 — RSASSA-PKCS1-v1_5 + SHA-256
};
const digest = digestName[coseAlg];
if (!digest) throw new Error(`Unsupported COSE alg: ${coseAlg}`);
const publicKey = createPublicKey({ key: spkiPublicKeyBuffer, format: 'der', type: 'spki' });
// dsaEncoding: 'der' (default) — expects ASN.1 DER-encoded ECDSA signature
// If authenticator returns P1363 format, convert r||s to DER first
return verify(digest, signedPayload, publicKey, rawSignature);
}
'ES256'is NOT a valid OpenSSL digest name. Passing it throwsERR_OSSL_EVP_UNSUPPORTED. Always use the digest identifier ('SHA256'), not the COSE algorithm name.
Step 5 — Convert P1363 signature to DER if needed
If rawSignature.length === 64 for an ES256 assertion, the authenticator returned P1363 format. Convert before passing to crypto.verify():
function p1363ToDer(sig: Buffer): Buffer {
// r and s are each 32 bytes in P1363
let r = sig.subarray(0, 32);
let s = sig.subarray(32, 64);
// Prepend 0x00 if high bit is set (DER positive integer requirement)
if (r[0] & 0x80) r = Buffer.concat([Buffer.from([0x00]), r]);
if (s[0] & 0x80) s = Buffer.concat([Buffer.from([0x00]), s]);
// Encode as DER SEQUENCE { INTEGER r, INTEGER s }
const rDer = Buffer.concat([Buffer.from([0x02, r.length]), r]);
const sDer = Buffer.concat([Buffer.from([0x02, s.length]), s]);
const seq = Buffer.concat([Buffer.from([0x30, rDer.length + sDer.length]), rDer, sDer]);
return seq;
}
Step 6 — Validate clientDataJSON with timing-safe challenge comparison
This validation must run before the cryptographic check, not after. The challenge-response authentication flow requires that the server consume and delete the stored challenge immediately after use to prevent replay.
import { timingSafeEqual } from 'crypto';
function validateClientData(
rawClientDataJSON: Buffer,
storedChallenge: string, // base64url string from cache/session
expectedOrigin: string, // exact RP origin, e.g. 'https://example.com'
expectedRpId: string // RP ID, e.g. 'example.com'
): { type: string; challenge: string; origin: string } {
const clientData = JSON.parse(rawClientDataJSON.toString('utf8'));
// WebAuthn spec §7.2 step 11: type must be 'webauthn.get'
if (clientData.type !== 'webauthn.get') {
throw new Error(`Invalid clientDataJSON type: ${clientData.type}`);
}
// §7.2 step 12: origin must match exactly
if (clientData.origin !== expectedOrigin) {
throw new Error(`Origin mismatch: got ${clientData.origin}`);
}
// §7.2 step 13: timing-safe challenge comparison
const storedBuf = Buffer.from(storedChallenge, 'base64url');
const receivedBuf = Buffer.from(clientData.challenge, 'base64url');
if (storedBuf.length !== receivedBuf.length) {
throw new Error('Challenge length mismatch');
}
if (!timingSafeEqual(storedBuf, receivedBuf)) {
throw new Error('Challenge mismatch — possible replay attempt');
}
return clientData;
}
Step 7 — Enforce signCount monotonicity and update atomically
The signCount check protects against cloned authenticators. Per WebAuthn spec §7.2 step 17, a non-zero signCount that does not increase is evidence of cloning. Sync passkeys (iCloud Keychain, Google Password Manager) legitimately report signCount 0 indefinitely — treat 0 as exempt:
async function finalizeAssertion(
credential: { id: string; signCount: number },
newSignCount: number,
db: Database
): Promise<void> {
// signCount=0 means the authenticator doesn't implement a counter (sync passkeys)
const isSyncPasskey = newSignCount === 0 && credential.signCount === 0;
if (!isSyncPasskey && newSignCount <= credential.signCount) {
// Non-zero counter regression — strong signal of cloned authenticator
throw new Error(`signCount regression: stored=${credential.signCount} incoming=${newSignCount}`);
}
// Update atomically — only after full verification succeeds
await db.credentials.updateOne(
{ id: credential.id },
{ $set: { signCount: newSignCount, lastUsedAt: new Date() } }
);
}
Verification and Testing
Confirm base64url decoding
# Must produce exactly 32 bytes for a 32-byte challenge
node -e "
const b = Buffer.from(process.argv[1], 'base64url');
console.assert(b.length === 32, 'challenge must be 32 bytes');
console.log('OK', b.length, 'bytes');
" "$CHALLENGE_B64URL"
Confirm SPKI structure
echo "$SPKI_HEX" | xxd -r -p | openssl asn1parse -inform DER
# Line 1: SEQUENCE
# Line 2: SEQUENCE containing EC OID + curve OID
# Line 3: BIT STRING (the public key point)
Unit test assertion skeleton (Vitest/Jest)
import { describe, it, expect } from 'vitest';
describe('verifyWebAuthnAssertion', () => {
it('returns true for a valid ES256 assertion with known test vectors', async () => {
// Use FIDO Alliance conformance test vectors from
// https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-client-to-authenticator-protocol-v2.0-rd-20180702.html
const result = verifyWebAuthnAssertion(knownSpki, -7, knownPayload, knownSignature);
expect(result).toBe(true);
});
it('returns false when clientDataJSON is tampered', async () => {
const tamperedPayload = Buffer.concat([rawAuthData, createHash('sha256').update(Buffer.from('tampered')).digest()]);
expect(verifyWebAuthnAssertion(spki, -7, tamperedPayload, sig)).toBe(false);
});
it('throws for unsupported COSE alg', () => {
expect(() => verifyWebAuthnAssertion(spki, -999, payload, sig)).toThrow('Unsupported COSE alg');
});
});
authenticatorData flags byte inspection
The UV (User Verified) flag is bit 2 of the flags byte at authenticatorData[32]:
node -e "
const authData = Buffer.from(process.argv[1], 'base64url');
const flags = authData[32];
console.log({
UP: Boolean(flags & 0x01), // User Presence
UV: Boolean(flags & 0x04), // User Verification
BE: Boolean(flags & 0x08), // Backup Eligibility
BS: Boolean(flags & 0x10), // Backup State
AT: Boolean(flags & 0x40), // Attested credential data present
ED: Boolean(flags & 0x80), // Extension data present
});
" "$AUTH_DATA_B64URL"
Pitfalls
Logging cryptographic material. Debug logging that captures raw signature, clientDataJSON, or the public key bytes violates compliance obligations and exposes replay material. Emit only a hashed credential_id, an errorType, and a timestamp. Never log the actual COSE key, SPKI bytes, or signature.
Using === for challenge comparison. String equality leaks timing information that an attacker on the same host can measure. crypto.timingSafeEqual() is mandatory. Both buffers must have the same length before the call — check lengths first and fail with a generic error if they differ.
Storing raw COSE keys and converting on every request. The CBOR parse and SPKI reconstruction add latency and introduce a point of encoding drift if the CBOR library is updated. Convert to SPKI/DER at registration time and store the SPKI bytes directly, as covered in how to store WebAuthn public keys in PostgreSQL.
Related
- Implementing Authentication Verification Logic — parent cluster covering the full assertion verification pipeline
- Best Practices for FIDO2 Challenge Generation — CSPRNG entropy, base64url encoding, and challenge lifecycle management
- How to Store WebAuthn Public Keys in PostgreSQL — persisting SPKI/DER public keys and COSE algorithm IDs at registration
- Cryptographic Algorithms Supported by WebAuthn — COSE alg IDs, curve parameters, and algorithm selection guidance