
A passkey assertion fails silently, and almost never because the signature is wrong. In practice the assertion is cryptographically intact and the relying party still rejects it, because a binding check failed first: the challenge in clientDataJSON is not the one your server minted, the origin is the staging URL you forgot to allow, or the rpIdHash belongs to a different relying-party domain than the one the request arrived on. Those three fields are plain text inside a nested, two-layer container, which is exactly why they are so easy to get wrong and so hard to see from the outside.
That container is the reason a dedicated decoder exists. A WebAuthn assertion arrives as three separately base64url-encoded blobs, and the middle one — authenticatorData — is raw binary with a fixed-width header, a flag byte that packs eight distinct bits, and a 4-byte counter. Nothing about that is legible without unpacking it. This guide walks the structure byte by byte, shows which flag bits decide whether an assertion is even worth verifying, and walks through a real debug session where four different defects each look identical from the server side.
If you want to jump straight to unpacking a real payload, the WebAuthn Passkey Assertion Decoder & Challenge Debugger takes a full assertion and prints the decoded authenticatorData, every named flag, the binding-check verdicts, and — when you paste a COSE public key — the signature verification result.
The Three Blobs An Assertion Arrives In
A WebAuthn assertion response is not one object. It is a container with three independently encoded payloads, and each one is decoded differently:
- clientDataJSON — UTF-8 JSON, base64url-encoded. Carries
type,challenge,origin,crossOrigin, andcrossOriginIsolated. - authenticatorData — raw binary, base64url-encoded. Carries
rpIdHash, the flag byte,signCount, and optionally attested credential data plus extensions. - signature — DER-wrapped ASN.1 for ES256 and RS256, or 64 raw bytes for Ed25519. Produced by the authenticator over a message your server must reconstruct exactly.
The consequence is that a server cannot “just verify the signature.” Verification requires the server to first reassemble the exact byte string the authenticator signed, and that string is defined by the COSE algorithm identifier negotiated at registration time. For ES256 it is authenticatorData || SHA-256(clientDataJSON). For RS256 it is also the hash-prefixed digest of authenticatorData || SHA-256(clientDataJSON), wrapped in PKCS#1 v1.5 padding. For Ed25519 — and this is the detail that trips up almost every first implementation — the signed message is authenticatorData || clientDataJSON with the JSON hashed inside the Ed25519 algorithm, not pre-hashed by the server. One extra SHA-256 and verification fails with a perfectly good key.
Because the JSON is hashed in one case and not the other, “the same assertion, same key, different verifier” can disagree. If you have ever gotten a 100% signature failure only on Ed25519 credentials, this is why.
Decoding the JSON layer is not hard, but it is fiddly to do by hand when the payload is long. The Base64URL Encoder/Decoder handles the transport encoding, and the JWT Decoder unpacks the base64url-and-JSON layer cleanly for anything shaped like a signed token.
Byte by Byte Inside authenticData
authenticatorData has a fixed 37-byte header before any optional data. Those 37 bytes are not variable-length, which makes them the one part of the assertion you can validate structurally with a ruler:

- rpIdHash — 32 bytes.
SHA-256of the relying-party ID, typically the registrable domain. This is what binds the credential to the domain. - flags — 1 byte. Eight named bits, described in the next section.
- signCount — 4 bytes, big-endian unsigned. A monotonically increasing counter maintained by the authenticator.
- attestedCredentialData — variable, present only when the AT flag is set. On registration this follows immediately; on assertion it is normally absent.
- extensions — variable CBOR map. Present only when the extension-data flag is set.
rpIdHash is the field worth checking first, because it is the one your server can compute. Hash the expected RP ID yourself and compare. If they differ, every subsequent step is meaningless, and the usual cause is not a cryptographic bug at all — it is a relying-party ID that was configured as the full origin including scheme and port when it must be a bare domain.
The signCount deserves a note on its own: it is a 32-bit value, so it wraps. A counter that goes backwards by a small negative delta after roughly 4.29 billion operations is arithmetic, not an attack. Authenticators that are hardware tokens and reset to zero on firmware update are a different matter entirely, and WebAuthn’s own guidance is that a counter regression on a previously-registered credential is a strong signal worth flagging rather than silently accepting.
Decoding the Flags Byte
The single byte at offset 32 decides how much of the rest of the blob you are allowed to believe. Reading it as a plain integer is the single most common decoding mistake:

- Bit 0 (0x01) UP — User Present. The user touched the authenticator. Always set on any assertion worth verifying.
- Bit 1 (0x02) UV — User Verified. Local biometric or PIN check passed. Strongly recommended by every major consumer deployment.
- Bit 2 (0x04) BE — Backup Eligibility. The credential can be synced to a backup.
- Bit 3 (0x08) BS — Backup State. The credential is currently backed up.
- Bit 4 (0x10) RFU1 — Reserved. A server that reads meaning into it is reading noise.
- Bit 5 (0x20) AT — Attested credential data included. Set on registration; usually clear on assertion.
- Bit 6 (0x40) ED — Extension data included. If clear, there are no extensions and the blob ends early.
- Bit 7 (0x80) MF — Multi-factor, an extension-derived hint. Not a core bit in older revisions.
The practical decision is simple: refuse the assertion if UP is clear, because an assertion with no user presence is not an authentication. Treat a clear UV as a policy failure on any passkey-only flow. And when ED is clear, do not attempt to parse trailing bytes as a CBOR extensions map — you will either find nothing or invent structure.
Registration assertions are the mirror image. On those, AT is set and the bytes after signCount are the attested credential data: an 16-byte credential ID, a 2-byte credential public key length, the COSE key itself, and an optional extension block. If your parser assumes a 37-byte header on a registration blob and stops there, you will silently lose the credential ID.
Binding Checks: Challenge, Origin, rpIdHash
These four checks are the difference between a working passkey flow and a support queue. They are also the four that fail in production.

- 1. challenge — the value in
clientDataJSONmust equal the one your server minted, compared byte-for-byte. This is the replay defence: a stolen assertion is worthless if the challenge was already consumed. - 2. origin — must match an entry in the server’s allowlist exactly. Scheme, host and port all count.
httpsversushttpis a mismatch; a trailing slash is a mismatch; a preview-deployment hostname is a mismatch. - 3. rpIdHash — must equal
SHA-256of the RP ID you expect, computed server-side. - 4. type — must be
webauthn.getfor a login assertion andwebauthn.createfor a registration. Crossed type values indicate a confused-deputy path between your two endpoints.
Every one of these produces the same client-visible outcome: the sign-in button spins, then reports a generic failure. That is why they need to be checked in order and reported distinctly in your logs. In practice origin is the usual culprit — an allowlist written for the production domain and never extended to the staging host — and challenge is the usual culprit when a load balancer replicates session state but not the one-time challenge store, so a second node cannot find the value it is supposed to match against.
For the flow that sits immediately before all of this, the PKCE Code Verifier & Challenge Generator covers the authorization-request half of a passkey-adjacent sign-in, and the OAuth 2.0 / OIDC Authorization Code with PKCE visualizer shows where the WebAuthn ceremony gets invoked inside the wider flow.
Signature Verification and the COSE Key
To verify, your server needs the credential’s public key in COSE form — the same CBOR-encoded structure the authenticator returned at registration. The relevant labels are 1 for key type, 3 for algorithm and -1 for the curve:
- label 1 (kty) —
2is an elliptic-curve key,1is an RSA key. - label 3 (alg) — the negotiated algorithm, typically
-7for ES256,-257for RS256,-8for Ed25519. - label -1 (crv) — the named curve,
1for P-256. - label -2 (x) and label -3 (y) — the uncompressed public coordinates for EC2 keys.
- label -1 / label -2 (n, e) — modulus and exponent for RSA keys.
Because the key is CBOR, the practical move is to decode it as CBOR first, confirm the algorithm label matches what the client negotiated, and only then hand the coordinates to your crypto library. A mismatch between the registered algorithm and the verification algorithm is a genuine, common bug — and it produces the same generic rejection as everything else. The CBOR Converter handles that unpacking step, and the JWK Generator & Parser is the right tool if you need the same key material in JWK form for a library that will not accept COSE directly.
Two verification details are worth internalising. First, DER signatures are strictly encoded — a signature that verifies after lenient DER parsing is a signal that something in your stack is being too permissive. Second, for Ed25519 the message is authenticatorData || clientDataJSON with no external hash, and the raw 64-byte signature is not ASN.1 at all. Handing that to a DER parser yields a confusing error rather than an honest one.
A Worked Debug Session
Here is a realistic failure shape. The client sends an assertion, the server logs verification failed, and the response body contains nothing useful:
- clientDataJSON — base64url of JSON with
type,challenge,origin,crossOrigin. - authenticatorData — 37 bytes minimum:
rpIdHash, one flag byte, four counter bytes. - flag byte — decodes to UP set, UV set, AT clear.
- rpIdHash mismatch — the hash does not equal
SHA-256of the expected RP ID. - origin — the allowlist has only the production domain; staging is missing.
- challenge — minted on node A, consumed on node B with no shared store.
- COSE key label 3 — algorithm is
-8, so the server’s Ed25519 path applies. - result — a valid assertion rejected for three independent configuration reasons at once.
Note what the worked session actually demonstrates: the assertion was never forged, and the signature probably verifies fine. Three separate deployment-configuration defects produced one indistinguishable error. This is the single strongest argument for running the layers in order — decode, bind, verify — because fixing them in any other order means fixing them one support ticket at a time.
What the Decoder Shows You That Logs Do Not
The reason to decode rather than log is that a decoded assertion separates state from verdict. The flag byte is state: it tells you what the authenticator claims happened. The binding checks are verdicts: they tell you what your server concluded. A log line mixes both into one string, which is why the same message text appears for eight different underlying situations.
Concretely, a decoded assertion makes four facts immediately visible. Whether rpIdHash is the domain you expect, or the domain of a sibling tenant in a multi-tenant deployment. Whether signCount is advancing, stationary, or reset — a counter stuck at exactly zero across many sign-ins is an emulator or a software token, not a hardware authenticator. Whether UP and UV are both set, which tells you if the ceremony actually involved the user at all. And whether the COSE algorithm label matches the branch your verifier is about to take, which is the check that catches the Ed25519 double-hash bug before it becomes a signature failure.
The same decode also answers the question every passkey support thread opens with — “is this a real assertion at all?” A structurally invalid blob fails immediately: fewer than 37 bytes of authenticatorData, a clientDataJSON that does not parse as JSON, a type field that is neither webauthn.get nor webauthn.create. Those three structural checks catch a large share of malformed payloads, and they cost nothing to run before any crypto is attempted.
Run the same three layers in reverse when you are debugging a successful sign-in that should have failed, such as a counter regression or a credential registered to the wrong relying-party domain. The decode step shows you immediately whether the credential you are evaluating is even the credential you meant to be evaluating.
A Pre-Passkey Sanity Checklist
Before you ship, or when you inherit a broken passkey flow, check these in order:
- Decode first. Never verify a payload you have not visually unpacked. Confirm three blobs, and confirm the flag byte reads as a set of bits.
- Compute your own
rpIdHash. Hash the RP ID server-side and compare against offset 0. If it differs, stop — nothing downstream can be trusted. - Allowlist origins exactly. Every environment that can complete a ceremony needs its own entry, with scheme and port included.
- Store challenges where every node can reach them. Single use, short TTL, and shared across the fleet.
- Branch on the registered algorithm. ES256 and RS256 hash
clientDataJSONbefore signing; Ed25519 does not. Derive the message from the COSE label, not from a hardcoded default. - Reject UP clear. An assertion without user presence is not an authentication.
- Make signCount regressions observable. Log them, page on them if the credential is expected to be hardware-backed.
- Distinguish your error codes. The client sees one generic failure; your logs should see four.
Every one of these is a configuration or plumbing check, not a cryptography check. That is the recurring lesson of passkey debugging: the crypto is almost always right, and the bindings are almost always wrong. Once authenticatorData is unpacked and the four bindings are compared one at a time, a “WebAuthn is broken” ticket becomes a two-minute diff.
Browse the rest of the toolset at elysiatools.com when you need the adjacent primitives — key generation, encoding layers, and the certificate inspection that passkey deployment pipelines tend to need next.