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.

WebAuthn Assertion Signature Verification Data Flow Diagram showing how authenticatorData, clientDataJSON, signature, and publicKey flow from the authenticator through base64url decoding, COSE-to-SPKI conversion, payload construction, and finally into crypto.verify() in Node.js. Authenticator authenticatorData clientDataJSON signature · publicKey (COSE) base64url decode Buffer.from(val, 'base64url') COSE → SPKI/DER KeyObject from SubjectPublicKeyInfo Payload Construction authData || SHA-256(clientData) Buffer.concat([authData, hash]) crypto.verify() 'SHA256', payload, spkiKey, sig true / false rawSignature

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 throws ERR_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