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–31 —
rpIdHash, the SHA-256 of the RP ID. A mismatch here is the root of mostSecurityErrorand “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–36 —
signCount, 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.
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
- WebAuthn & FIDO2 Protocol Fundamentals — the parent pillar and authenticatorData layout
- Fixing InvalidStateError on Registration — the excludeCredentials-triggered registration error
- Resolving WebAuthn SecurityError (RP ID and Origin) — the rpIdHash / origin mismatch class
- Implementing Authentication Verification Logic — the server checks these bytes feed
- Debugging and Observability for Client-Side WebAuthn — the browser-side DOMException triage