Debugging WebAuthn Protocol Errors

WebAuthn failures split into two layers: the browser’s DOMException (surfaced before anything reaches your server) and the RP’s verification error (surfaced when the assertion or attestation fails a spec check). Both are opaque unless you decode the underlying bytes — the authenticatorData flags, the rpIdHash, the signCount, and the clientDataJSON fields. This page is the protocol-level triage reference that connects each symptom to its byte-level cause and the tool that reproduces it. It is the protocol counterpart to the client-side debugging cluster and the server-side debugging cluster, and sits under WebAuthn & FIDO2 Protocol Fundamentals.


Concept Definition and Spec Grounding

Every WebAuthn ceremony ends in an authenticatorData structure whose layout is fixed (WebAuthn L2 §6.1). Debugging almost always means decoding these bytes:

  • Bytes 0–31rpIdHash, the SHA-256 of the RP ID. A mismatch here is the root of most SecurityError and “signature valid but wrong RP” bugs.
  • Byte 32 — the flags bitfield: UP (bit 0, user present), UV (bit 2, user verified), BE (bit 3, backup eligible), BS (bit 4, backup state), AT (bit 6, attested credential data present), ED (bit 7, extensions present).
  • Bytes 33–36signCount, a big-endian uint32 whose non-increase signals cloning or a counter-less authenticator.

Client-side, the ceremony can reject before this ever reaches the server; the DOMException name (not message) is the stable signal, as detailed in the client-side error mapping. Server-side, the same bytes drive the checks in implementing authentication verification logic.


Architecture and Data Flow

The diagram traces a failure from symptom to byte-level cause across the client and server boundary.

WebAuthn protocol error trace Two entry points (client DOMException and server verification error) both resolving down to specific authenticatorData regions: rpIdHash, flags byte, and signCount. Client DOMException Server verify error SecurityError / InvalidStateError bad signature / flag / counter decode authenticatorData bytes 0–31 rpIdHash byte 32 UP/UV/BE/AT flags bytes 33–36 signCount

Implementation Guide

Step 1 — Decode authenticatorData into named fields

function parseAuthData(authData: Uint8Array) {
  const rpIdHash = authData.subarray(0, 32);
  const flags = authData[32];
  const signCount = new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0, false);
  return {
    rpIdHash: Buffer.from(rpIdHash).toString('hex'),
    up: !!(flags & 0x01),  // user present
    uv: !!(flags & 0x04),  // user verified
    be: !!(flags & 0x08),  // backup eligible
    bs: !!(flags & 0x10),  // backup state
    at: !!(flags & 0x40),  // attested credential data
    ed: !!(flags & 0x80),  // extension data
    signCount,
  };
}

Step 2 — Verify the rpIdHash matches SHA-256(rpId)

The most common silent bug: rpId differs from what the client sent (a subdomain, a trailing dot, www.).

import { createHash } from 'crypto';
const expected = createHash('sha256').update('example.com').digest('hex');
if (parsed.rpIdHash !== expected) {
  throw new Error(`rpIdHash mismatch: got ${parsed.rpIdHash}, expected SHA-256(rpId)`);
}

Step 3 — Assert the flag policy

Reject assertions that fail your UP/UV requirements before trusting the signature.

if (!parsed.up) throw new Error('UP flag not set — no user presence');
if (requireUV && !parsed.uv) throw new Error('UV required but not verified');

Step 4 — Reproduce deterministically

Use the DevTools virtual authenticator or a conformance vector to force each condition (see Verification).


Validation Checklist


Error Reference Table

Error / Condition HTTP Status Trigger Diagnostic
SecurityError (client) rpId not a suffix of origin / insecure origin Compare rpId vs location.hostname; require HTTPS
InvalidStateError (client) excludeCredentials matched an existing credential Expected on re-registration; treat as “already enrolled”
rpIdHash mismatch 400 Stored rpId differs from ceremony rpId SHA-256(rpId) vs bytes 0–31
UP not set 400 Silent/testing authenticator Reject; require user presence
UV required, not set 401 userVerification: 'required' unmet Check flag bit 2; verify client requested UV
signCount not increasing 401 Cloned credential or counter-less authenticator Compare stored vs received; flag or waive per policy
Bad signature 401 Wrong public key or corrupted authData/clientData Re-derive signed data: authData ‖ SHA-256(clientDataJSON)

Platform and Library Notes

@simplewebauthn/server

verifyAuthenticationResponse throws with descriptive messages and exposes authenticationInfo.newCounter; compare it against the stored counter. It validates rpIdHash, flags, and origin internally — read the thrown message to localise the failing check.

Chrome DevTools WebAuthn tab

Create a virtual authenticator, toggle “user verification” and “resident key”, and force signCount behaviour to reproduce UP/UV/counter failures without hardware.

FIDO Conformance Tools

The FIDO Alliance conformance test vectors provide deterministic authenticatorData samples for regression tests of your parser.

py_webauthn / WebAuthn4J

Both expose parsed flag fields; align your byte offsets with theirs (byte 32 flags, bytes 33–36 counter) when cross-checking a discrepancy.


Pitfalls and Security Hardening

1. Reading err.message for classification. Root cause: message varies by engine/version. Mitigation: branch on err.name and on decoded bytes.

2. Wrong rpId granularity. Root cause: registering under app.example.com but verifying against example.com (or vice versa). Mitigation: fix rpId to the intended registrable domain and keep it constant.

3. Trusting a signature before checking flags. Root cause: verifying crypto first, policy later. Mitigation: assert UP/UV before accepting the assertion.

4. Ignoring BE/BS backup flags. Root cause: treating all passkeys as device-bound. Mitigation: record BE/BS to reason about synced-credential exposure.


Related