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.

The fixed header, byte by byte The first 37 bytes are always present and always in the same place, in both ceremonies. Thirty-seven bytes that never move bytes 0–31 rpIdHash — SHA-256 of the RP ID the authenticator signed for byte 32 flags — user presence, verification, backup bits, and two structure bits bytes 33–36 signCount — unsigned 32-bit, big-endian, frequently zero byte 37 onward present only when the AT or ED flags are set

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.

Walking past the header Attested credential data is variable-length, so the walk must read the credential id length before it can find the public key. Only the first two lengths are fixed offset 37 AAGUID, 16 bytes offset 53 id length, 2 bytes offset 55 credential id, that many bytes after that the COSE public key Extension data may follow the key, so use a CBOR decoder that reports how much it consumed.

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.

Registration versus assertion layout The same structure serves both ceremonies; only the optional sections differ. One decoder, two shapes — distinguished by one bit registration AT flag set AAGUID present credential id present COSE public key present typically 100+ bytes assertion AT flag clear no AAGUID no credential id no public key exactly 37 bytes A length of exactly 37 is a reliable hint that you are holding an assertion, but the flag is the authority.

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