Everyone pitching passkeys makes it sound effortless: "It's just public key cryptography! No more password resets, no phishing, instant biometrics!"
Then you roll it out to 50,000 real-world users in production, and your exception trackers immediately light up with mysterious verification failures:
WebAuthnVerificationError: Signature counter regression detectedInvalid CBOR COSE key encoding: unexpected map key typeMismatched clientDataJSON challenge hash
Why does WebAuthn break so easily when moving from localhost demos to real production devices?
Because passkeys synced via Apple iCloud Keychain or Google Password Manager violate assumptions that backend developers have relied on for a decade with physical security keys.
Here is the deep architectural autopsy of WebAuthn binary payloads, why naive signCount enforcement locks out legitimate users, and how to build a resilient, battle-tested verification pipeline.
1. The Anatomy of authenticatorData: Binary Packing, Not JSON
When a user taps Touch ID, Face ID, or a hardware YubiKey, the browser doesn't send clean JSON. The browser returns an authenticatorData binary buffer (Base64URL-encoded) packed with strict byte offsets:
┌───────────────────────────────────────────────────────────────────────────┐
│ authenticatorData Byte Layout │
├─────────────────────┬───────────┬──────────────┬──────────────────────────┤
│ Field │ Offset │ Length │ Description │
├─────────────────────┼───────────┼──────────────┼──────────────────────────┤
│ rpIdHash │ Byte 0 │ 32 Bytes │ SHA-256 of Relying Party │
│ flags │ Byte 32 │ 1 Byte │ Bitfield (UP, UV, BE, BS)│
│ signCount │ Byte 33 │ 4 Bytes │ 32-bit unsigned BigEndian│
│ attestedCredential* │ Byte 37 │ Variable │ AAGUID + CredID + COSE │
│ extensions* │ Variable │ Variable │ Optional CBOR extensions │
└─────────────────────┴───────────┴──────────────┴──────────────────────────┘
* Present only when specific flag bits (AT, ED) are set to 1.
Parsing the 1-Byte flags Bitfield
Byte 32 is a packed bitfield that dictates the security context of the ceremony:
const flags = authDataBuffer[32];
const UP = Boolean(flags & 0x01); // Bit 0: User Present (test of user presence)
const UV = Boolean(flags & 0x04); // Bit 2: User Verified (PIN, fingerprint, Face ID)
const BE = Boolean(flags & 0x08); // Bit 3: Backup Eligibility (Multi-device passkey)
const BS = Boolean(flags & 0x10); // Bit 4: Backup State (Currently synced to cloud)
const AT = Boolean(flags & 0x40); // Bit 6: Attested Credential Data Included
const ED = Boolean(flags & 0x80); // Bit 7: Extension Data Included
If your server requires biometric verification for high-risk actions, asserting UP === true is not enough — you must assert UV === true. But the real minefield starts at Byte 33.
2. Trap #1: The signCount Desync with Synced Passkeys
In traditional FIDO2 hardware keys (like YubiKeys), the device maintains an internal monotonic counter. Every time a credential signs an authentication assertion, signCount increments by 1.
The FIDO2 specification originally stated:
"If the counter received is less than or equal to the stored counter, the relying party SHOULD fail authentication, as this may indicate a cloned authenticator."
Why this explodes with modern Passkeys:
Synced passkeys (Apple iCloud Keychain, Google Password Manager, 1Password, Bitwarden) are Multi-Device Credentials.
-
Apple iCloud Keychain always returns
signCount = 0: Apple deliberately does not increment or synchronize a global monotonic counter across devices to preserve zero-knowledge end-to-end encryption and avoid cross-device counter locking. - Out-of-order authentications across devices: If an authenticator does maintain a local counter, a user authenticating on their laptop on an airplane (counter 14) and later on their phone with connectivity (counter 12) will trigger a false-positive clone alarm.
The Production Fix:
Check the Backup Eligibility (BE) flag in the bitfield:
export function verifySignatureCounter(
storedSignCount: number,
incomingSignCount: number,
flags: number
): boolean {
const isBackupEligible = Boolean(flags & 0x08); // BE flag
// If both counters are 0, signCount is not supported by this authenticator (e.g. Apple)
if (storedSignCount === 0 && incomingSignCount === 0) {
return true;
}
// Strict monotonic check for dedicated hardware tokens (Single-Device Credentials)
if (!isBackupEligible) {
if (incomingSignCount <= storedSignCount) {
console.error(`Cloned token alert: incoming ${incomingSignCount} <= stored ${storedSignCount}`);
return false;
}
return true;
}
// Multi-device synced credentials: Relax strict rejection if counter drops,
// but log anomalous zero-jumps for risk-based anomaly scoring.
return true;
}
3. Trap #2: Base64URL vs Standard Base64 Encoding Mismatches
WebAuthn mandates Base64URL (RFC 4648 §5), which strips padding (=) and substitutes:
-
+➔- -
/➔_
A classic bug occurs when developers use standard atob() or Buffer.from(str, 'base64') without converting characters first.
When verifying the cryptographic signature:
// The signature covers: authData || SHA-256(clientDataJSON)
const clientDataHash = crypto.createHash('sha256').update(rawClientDataJSON).digest();
const verificationPayload = Buffer.concat([rawAuthData, clientDataHash]);
const isValid = crypto.verify(
'sha256',
verificationPayload,
publicKey,
signature
);
If rawClientDataJSON has even one padding byte altered or normalized by an intermediate API gateway or JSON parser, SHA-256(clientDataJSON) will mismatch completely, and cryptographic verification will fail with an unhelpful false boolean. Always preserve and verify the raw byte buffer as transmitted by the client.
4. Trap #3: CBOR & COSE Key Decoding (Negative Integer Map Keys)
During credential registration, the public key is packed in COSE (CBOR Object Signing and Encryption) format.
In JSON, object keys are always strings. In COSE/CBOR, keys are integers, and some are negative:
| COSE Parameter | Integer Key | Description | Example (ES256) |
|---|---|---|---|
kty |
1 | Key Type |
2 (EC2) |
alg |
3 | Cryptographic Algorithm |
-7 (ES256) |
crv |
-1 | Curve Parameter |
1 (P-256) |
x |
-2 | X-coordinate (32 bytes) | Raw byte buffer |
y |
-3 | Y-coordinate (32 bytes) | Raw byte buffer |
If your CBOR parser converts keys into stringified keys like "-1" or ignores negative keys, extracting the public key will silently corrupt the X and Y coordinates.
Here is how to extract an uncompressed public key directly from COSE parameters:
import cbor from 'cbor';
export function coseToRawPublicKey(coseBuffer: Buffer): Buffer {
const coseMap = cbor.decodeFirstSync(coseBuffer);
const kty = coseMap.get(1); // 2 = EC2
const alg = coseMap.get(3); // -7 = ES256
const crv = coseMap.get(-1); // 1 = P-256
const x = coseMap.get(-2); // Buffer (32 bytes)
const y = coseMap.get(-3); // Buffer (32 bytes)
if (kty !== 2 || alg !== -7 || crv !== 1) {
throw new Error(`Unsupported COSE algorithm or key type: ${alg}`);
}
// Uncompressed EC point format: 0x04 || X || Y (65 bytes total)
return Buffer.concat([Buffer.from([0x04]), x, y]);
}
5. Production Hardening Checklist
Before launching WebAuthn to production:
- [x] Store
rpIdexplicitly: Ensure the Relying Party ID is strictly matched to your canonical domain (e.g.,example.com, not including protocol or port). - [x] Store raw public keys in SPKI or JWK: Convert COSE keys to standard PEM/SPKI format during registration so downstream services don't need CBOR dependencies.
- [x] Store the
aaguid: The Authenticator Attestation GUID allows you to display user-friendly device icons (e.g. Apple Touch ID, Windows Hello, YubiKey 5). - [x] Verify
clientDataJSON.origin: Reject mismatches to prevent cross-origin phishing attacks. - [x] Zero-Trust Client-Side Inspection: Never log raw client private credentials in backend telemetry.
🛠️ Free Zero-Trust Engineering Utility
If you are debugging WebAuthn ceremonies, inspecting raw base64url payloads, or validating CBOR authData flags, check out our free zero-trust browser utility:
👉 WebAuthn & Passkey Payload Inspector
Inspects authenticator data bitfields, extracts AAGUIDs, and converts CBOR COSE keys to PEM 100% locally in your browser with zero network transmission.
What has been your biggest headache when implementing WebAuthn or passkeys in production? Have you run into the multi-device signCount issue? Let's discuss in the comments below!
Top comments (0)