Decoding authenticatorData Byte Layout
Every WebAuthn ceremony returns an authenticatorData structure, and almost every protocol-level question — which relying party was signed for, whether a human was verified, whether the credential is backed up — is answered by reading four fields at fixed offsets. This page covers the exact layout, the variable-length walk that follows it during registration, and the decoding mistakes that produce plausible but wrong values. It sits under Understanding WebAuthn vs FIDO2 Architecture, and the flags byte it describes has its own dedicated breakdown.
Layout Reference
| Offset | Length | Field | Always present |
|---|---|---|---|
| 0 | 32 | rpIdHash |
yes |
| 32 | 1 | flags | yes |
| 33 | 4 | signCount |
yes |
| 37 | 16 | AAGUID | only when AT is set |
| 53 | 2 | credential id length | only when AT is set |
| 55 | variable | credential id | only when AT is set |
| after id | variable | COSE public key | only when AT is set |
| after key | variable | extension outputs | only when ED is set |
Everything before offset 37 is fixed. Everything after it is conditional and variable-length, which is why the two halves need different decoding strategies.
Root Cause Analysis
1. Slicing without checking the flags. The most common defect. A decoder that unconditionally reads sixteen bytes at offset 37 will happily produce an AAGUID from an assertion, where those bytes do not exist. The result is a value that looks like an identifier and corresponds to nothing.
2. Reading signCount as signed or little-endian. The field is an unsigned 32-bit big-endian integer. Reading it little-endian produces enormous numbers that pass a naive “greater than stored” check forever; reading it as signed produces negatives near the top of the range.
3. Re-encoding before hashing. authenticatorData is signed as received. Any decoder that parses it into an object and then reconstructs the bytes for verification will eventually produce a different byte sequence — a different CBOR encoding of the same key, a normalised length prefix — and the signature check fails for reasons that look cryptographic and are not.
4. Assuming the public key ends the structure. When extension data is present, the COSE key is followed by more CBOR. A decoder that treats “everything after the credential id” as the key will fail on any authenticator that returns extension output.
Step-by-Step Resolution
Step 1 — read the fixed header first
Take the RP ID hash, the flags byte and the counter by offset. These three are unconditional and cost nothing.
Step 2 — branch on the AT flag
If the attested-data bit is clear, the structure ends at byte 37 unless extension data follows. Return early; there is no AAGUID, no credential id and no public key to find.
Step 3 — walk the variable section by length
Read the AAGUID, then the two-byte big-endian credential id length, then that many bytes of credential id. Do not search for delimiters; there are none.
Step 4 — decode the key with a CBOR reader that reports consumption
The COSE public key is a CBOR map whose length you do not know in advance. A decoder that tells you how many bytes it consumed is what lets you find where extension data begins, and what lets you assert that the structure ended where you expected.
Step 5 — keep the original bytes
Whatever you parse, retain the untouched buffer for signature verification. Parsing is for inspection; verification uses the bytes exactly as they arrived.
Verification and Testing
Compute the SHA-256 of your configured RP ID and compare it with bytes 0–31 of a real registration. That single comparison settles the most common protocol dispute — whose relying party the authenticator thought it was signing for — and it needs no library beyond a hash function.
Keep one registration payload and one assertion payload as fixtures and run both through the decoder. The assertion fixture is what proves the AT-flag guard is present; without it, a decoder that ignores flags passes every test.
Assert the counter’s type explicitly. A fixture from a hardware key with a counter above two billion will catch a signed-integer bug that no synthetic small value ever will.
Pitfalls
1. Treating the length as authoritative. A 37-byte payload is almost certainly an assertion, but the flags byte is what actually says so.
2. Normalising the buffer. Trimming, re-encoding or converting the bytes anywhere between receipt and verification breaks the signature.
3. Assuming a fixed credential id length. It varies by authenticator and can be long. Read the length field.
Frequently Asked Questions
Why is signCount so often zero?
Because the authenticator does not implement a counter. Synced platform passkeys generally report zero, since a value incrementing independently on several devices would go backwards constantly. Zero on either side means the check does not apply rather than that something is wrong.
Can I identify the authenticator from authenticatorData alone?
Only during registration, and only to model level, via the AAGUID — and even then the value is trustworthy only after the attestation statement containing it has been verified. An assertion carries no identifying information about the device at all.
Does the layout differ between WebAuthn levels?
The fixed header has not changed. Later levels defined additional flag bits, most importantly the backup-eligibility and backup-state bits, in space that was previously reserved. Code written against an earlier level still parses correctly; it simply ignores signals that have since become useful.
Should I store the decoded fields or the raw bytes?
Store the decoded facts you need — the counter, the backup bits, the AAGUID — and discard the raw authenticatorData once verification has succeeded. It is large, it is only meaningful together with the signature that covered it, and keeping it invites someone to re-verify against expectations that have since changed.
What is the ED flag for?
It signals that extension output follows the credential public key. Most deployments request no extensions and never see it set, but a decoder that assumes it is always clear will misparse responses from clients that add one — which some platforms do without being asked.
How do I hex-dump a payload safely during an investigation?
Decode the base64url value with the same canonical helper your verification path uses, then print the first forty bytes as hex alongside their offsets. Forty is enough to show the RP ID hash, the flags byte and the counter, which between them answer most questions, and it is short enough to paste into an incident channel without dragging a user handle along with it. Resist the temptation to dump the whole structure by default: the tail contains the credential id and, during registration, the public key, and neither adds anything to a first-pass diagnosis.
Is there a reason to keep a decoder rather than using a library?
Libraries handle this correctly and you should use one in the verification path. A small local decoder still earns its place as a diagnostic tool, because it can be pointed at a captured payload outside the request path, printed in a readable form, and used to answer questions about data your library has already rejected. The two are complements: the library decides, the decoder explains.
Related
- Understanding WebAuthn vs FIDO2 Architecture — the parent topic area and the layer this structure crosses
- Understanding the WebAuthn Flags Byte — the six bits at offset 32 in detail
- Decoding COSE Key Structures — what to do with the public key once the walk reaches it
- Debugging WebAuthn Protocol Errors — using these offsets to settle a verification dispute