FIDO2 & CTAP Protocol: How Hardware Security Keys Work
1. FIDO2 Overview: WebAuthn + CTAP2
FIDO2 is the umbrella standard for passwordless authentication from the FIDO Alliance in collaboration with the W3C. It consists of two core specifications:
- WebAuthn (W3C Recommendation) — The browser JavaScript API that websites call to create and use credentials.
navigator.credentials.create()andnavigator.credentials.get(). - CTAP2 (Client to Authenticator Protocol v2) — The wire protocol between the browser/client platform and the external security key. Uses CBOR (Concise Binary Object Representation) encoding over USB HID, NFC, or BLE.
+-------------+ WebAuthn JS +-----------+ CTAP2 (CBOR) +-----------+
| Relying |<--- (HTTPS) -------->| Browser |<--- (USB/NFC/BLE)--->| Security |
| Party (RP) | | (Client) | | Key |
+-------------+ +-----------+ +-----------+
|
+-----+------+
| Platform | (Windows Hello, Apple Touch ID,
| Authnr | Android biometric, etc.)
+------------+
The client-side architecture has two paths:
- External authenticator — USB/NFC/BLE security key (YubiKey, Feitian, SoloKeys, Google Titan). Browser communicates via CTAP2 CBOR over the transport protocol.
- Platform authenticator — Built-in TPM/Secure Enclave/TEE (Windows Hello, Apple Face ID, Android biometric). Browser communicates through platform-specific APIs; no CTAP on the wire.
CTAP1 (U2F) Backward Compatibility
CTAP1 is the original FIDO U2F protocol. CTAP2 authenticators must also support CTAP1 for backward compatibility with U2F-only relying parties. The key differences:
| CTAP1 (U2F) | CTAP2 (FIDO2) | |
|---|---|---|
| Encoding | Fixed binary format | CBOR |
| Credential storage | Key-wrapping (no on-device storage) | Resident keys (on-device) + non-resident (wrapped) |
| User verification | User Presence only (tap) | UP + UV (PIN, biometric) |
| Multiple accounts per RP | No | Yes (resident keys) |
| Discoverable credentials | No (username-first) | Yes (usernameless login) |
| Extensions | None | hmac-secret, credProtect, largeBlobKey, etc. |
2. WebAuthn JavaScript API Deep Dive
Credential Creation (Registration)
When a user registers a security key with a website, the RP calls navigator.credentials.create():
const credential = await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from(randomChallenge, c => c.charCodeAt(0)),
rp: { id: "example.com", name: "Example Corp" },
user: {
id: Uint8Array.from(userHandle, c => c.charCodeAt(0)),
name: "alice@example.com",
displayName: "Alice"
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 } // RS256
],
timeout: 60000,
attestation: "none", // "none" | "indirect" | "direct" | "enterprise"
authenticatorSelection: {
authenticatorAttachment: "cross-platform", // "platform" | "cross-platform"
residentKey: "preferred", // "discouraged" | "preferred" | "required"
userVerification: "preferred" // "discouraged" | "preferred" | "required"
}
}
});
// credential.response is AuthenticatorAttestationResponse
// Contains: attestationObject (CBOR), clientDataJSON
Credential Assertion (Authentication)
When a user logs in, the RP calls navigator.credentials.get():
const assertion = await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from(randomChallenge, c => c.charCodeAt(0)),
rpId: "example.com",
allowCredentials: [ // For non-discoverable credentials
{ id: credentialId, type: "public-key" }
],
userVerification: "preferred",
timeout: 30000
}
});
// assertion.response is AuthenticatorAssertionResponse
// Contains: authenticatorData, signature, userHandle (if resident key)
3. authData Structure: Byte-by-Byte Breakdown
The authenticatorData (authData) is the core binary data structure produced by the authenticator. It is included in both registration (AuthenticatorAttestationResponse) and authentication (AuthenticatorAssertionResponse). We also provide an interactive FIDO2 CBOR Attestation Parser that decodes authData and attestation objects in-browser.
Structure Layout
Offset Size Field ------ ---- ----- 0 32 RP ID Hash — SHA-256(originRpId) 32 1 Flags byte 33 4 Signature Counter — uint32 big-endian // ---- If AT flag (bit 6) is set: ---- 37 16 AAGUID — Authenticator Attestation GUID 53 2 Credential ID Length — uint16 big-endian (L) 55 L Credential ID — opaque byte string 55+L var COSE_Key — CBOR-encoded public key (variable length) // ---- If ED flag (bit 7) is set: ---- ... var Extension Data — CBOR map of extension outputs
Flags Byte (authData[32])
| Bit | Mask | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 | UP | User Present — user tapped the key or performed a gesture |
| 2 | 0x04 | UV | User Verified — PIN entered or biometric matched |
| 6 | 0x40 | AT | Attested Credential Data included (only in registration responses) |
| 7 | 0x80 | ED | Extension Data included |
Signature Counter
The 4-byte signature counter starts at 0 and increments with every successful assertion. A constant value of 0 indicates a counter-less authenticator (most modern FIDO2 keys). Non-zero counters protect against authenticator cloning: if the RP sees a counter that hasn't increased, the key may have been duplicated.
4. CTAP2 Protocol: Commands and Wire Format
| Command | CTAP CMD | Purpose |
|---|---|---|
| authenticatorMakeCredential | 0x01 | Create a new credential (registration) |
| authenticatorGetAssertion | 0x02 | Sign a challenge (authentication) |
| authenticatorGetInfo | 0x04 | Get authenticator capabilities and metadata |
| authenticatorClientPIN | 0x06 | Set/change/verify PIN, get PIN token |
| authenticatorReset | 0x07 | Factory reset — erase all credentials |
| authenticatorGetNextAssertion | 0x08 | Get next assertion (multiple credentials for RP) |
| authenticatorBioEnrollment | 0x09 | Manage biometric templates (fingerprint enrollment) |
| authenticatorCredentialManagement | 0x0A | Enumerate/delete resident credentials |
| authenticatorSelection | 0x0B | Select between multiple authenticators (USB hub) |
| authenticatorLargeBlobs | 0x0C | Read/write large blob data (certificates, up to 4096 bytes) |
authenticatorMakeCredential Parameters
{
1: Uint8Array // clientDataHash — SHA-256(clientDataJSON)
2: { // rp
"id": "example.com",
"name": "Example Corp" // optional
},
3: { // user
"id": Uint8Array, // userHandle
"name": "alice@example.com", // optional
"displayName": "Alice" // optional
},
4: [ // pubKeyCredParams
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
5: Uint32Array, // excludeList — credential IDs to exclude
6: { // extensions (optional)
"hmac-secret": true,
"credProtect": 2
},
7: { // options
"rk": true, // residentKey
"uv": true // userVerification
},
8: { // pinUvAuthParam (optional)
"pinProtocol": 2,
"pinAuth": Uint8Array // HMAC of pinToken over CDH
},
9: 2 // pinUvAuthProtocol (1 or 2)
}
CTAP2 Response: authenticatorMakeCredential
{
1: "packed", // fmt — attestation statement format
2: { // authData (CBOR byte string)
// RP ID Hash (32 bytes) + flags (1 byte) + counter (4 bytes)
// + AAGUID (16) + credIdLen (2) + credId (L) + COSE_Key
},
3: { // attStmt — format-specific
"alg": -7, // COSE AlgorithmIdentifier
"sig": Uint8Array, // ECDSA or RSA signature
"x5c": [cert1, ...] // X.509 certificate chain (optional)
}
}
5. Attestation Formats: packed, TPM, U2F, Android Key
packed (FIDO2 Standard)
packed is the default FIDO2 attestation format. The authenticator generates an attestation key pair at manufacturing time or per-credential. The attStmt contains:
- alg — COSE AlgorithmIdentifier (e.g., -7 for ES256)
- sig — Signature over
authenticatorData || clientDataHash - x5c — Optional X.509 certificate chain. If present, the first cert contains the attestation public key. If absent, this is self attestation (the credential public key is used as the attestation key — offers no hardware provenance).
TPM 2.0
Used by Windows Hello platform authenticators. The attStmt contains a TPM-generated TPMS_ATTEST structure (CBOR-encoded) and TPMT_SIGNATURE. The TPM's Attestation Identity Key (AIK) signs the credential, providing a hardware root of trust back to the TPM manufacturer (Infineon, STM, Nuvoton, etc.).
U2F (FIDO U2F Attestation)
Legacy attestation format compatible with CTAP1/U2F. Contains:
attStmt = {
"x5c": [attestationCert],
"sig": ECDSA signature (ASN.1 DER encoded)
}
The X.509 certificate is issued by the authenticator vendor's attestation CA. The RP verifies the cert chain up to the vendor root.
Apple Anonymous Attestation
Apple's platform authenticator (iCloud Keychain / Face ID / Touch ID) uses the "apple" attestation format. It does NOT contain x5c — instead, it contains a credCert (X.509 credential certificate issued by Apple's WebAuthn CA). The credential is tied to the user's Apple ID and can be verified through Apple's attestation service.
6. Resident Keys (Discoverable Credentials)
A resident key (also called discoverable credential) is a credential whose private key AND user handle (user.id/user.name/user.displayName) are stored on the authenticator itself. This enables usernameless login — the user goes to a login page, taps their security key, and the authenticator provides the credential ID + user handle without the RP having to send an allowCredentials list.
Resident vs Non-Resident
| Non-Resident (Wrapped) | Resident (Discoverable) | |
|---|---|---|
| Storage | Private key encrypted with authenticator master key, stored off-device (RP keeps it as credential ID) | Private key + user handle stored on authenticator flash |
| Credential ID | Contains encrypted private key + RP ID (opaque, ~250 bytes) | Opaque index into authenticator storage (~30-80 bytes) |
| Capacity | Unlimited (RP stores the blob) | Limited by authenticator flash (typically 25-100 slots) |
| usernameless | Not possible (RP must provide allowCredentials) | Yes — authenticator sends user handle |
| WebAuthn parameter | residentKey: "discouraged" | residentKey: "required" |
7. hmac-secret Extension and Other Extensions
hmac-secret (CTAP2 Extension)
The hmac-secret extension is one of the most powerful FIDO2 extensions. It allows a relying party to derive a symmetric secret from the credential's private key using HMAC. The authenticator computes:
HMAC-SHA-256(credRandom, salt1 || salt2)
Where credRandom is a 32-byte random value generated at credential creation time and stored securely on the authenticator. The RP provides up to two 32-byte salts, and the authenticator returns up to two 32-byte HMAC outputs.
This is the primitive that enables offline authentication and encryption use cases: password manager unlocking (Bitwarden's FIDO2 unlock), disk encryption key derivation, and cryptocurrency wallet seed encryption.
Other Important Extensions
| Extension | Identifier | Purpose |
|---|---|---|
| credProtect | credentialProtectionPolicy | Sets minimum UV requirement for credential: 1=optional, 2=required, 3=hardware-enforced |
| credBlob | credBlob | Stores a small blob (up to 32 bytes) alongside the credential on the authenticator |
| largeBlobKey | largeBlobKey | Derives a 32-byte key for encrypting data stored in the authenticator's large blob (up to 4096 bytes per RP) |
| minPinLength | minPinLength | RP requests the authenticator's minimum PIN length policy |
| prf | prf | Pseudorandom Function — successor to hmac-secret, more general-purpose |
8. Client PIN Protocol and User Verification
CTAP2 defines two Client PIN Protocol versions:
PIN Protocol v1
- Uses PIN/UV Auth Token — a shared secret established via ECDH key agreement (P-256) between platform and authenticator
- Platform sends PIN hash to authenticator; authenticator returns
pinTokenencrypted with session key - Subsequent commands include
pinAuth= HMAC-SHA-256(pinToken, commandData) as proof of PIN knowledge - 8 consecutive wrong PIN attempts → authenticator blocks PIN (requires reset to recover)
PIN Protocol v2
- Adds PIN/UV Auth Token permissions (bitmap: mc=makeCredential, ga=getAssertion, etc.)
- Adds UV authentication using built-in biometrics
- Adds min PIN length extension support
- Improved key agreement using HKDF
- Currently the recommended protocol for new implementations
User Verification Methods
| UV Method | UV Byte | Description |
|---|---|---|
| Presence only | — | No UV — just tap the key (UP flag set, UV flag clear) |
| PIN | — | 4-63+ alphanumeric characters, verified via pinProtocol |
| Fingerprint | 0x02 | Built-in fingerprint sensor (e.g., Feitian BioPass) |
| Internal UV | 0x04 | Device-level biometric/TEE (e.g., YubiKey Bio) |
9. FIDO2 Security Key Hardware Internals
A FIDO2 security key is essentially a secure element (SE) with a USB/NFC/BLE controller. Leading hardware platforms:
Common Hardware Platforms
| Platform | Chip | MCU Core | Key Storage | Used By |
|---|---|---|---|---|
| NXP A700x / A71CH | NXP Kinetis + SE050 | ARM Cortex-M4 | SE050 secure element | YubiKey 5 Series, Google Titan (2019+) |
| Infineon SLE 78 | Infineon SLE 78 | 16-bit secure MCU | On-chip EEPROM (mask ROM) | Feitian ePass, older YubiKeys |
| STM32 + ATECC608A | Microchip ATECC608A | ARM Cortex-M0/M3 | ATECC608A crypto element | SoloKeys Solo, open-source keys |
| Nordic nRF52840 | Nordic nRF52 + ARM CryptoCell | ARM Cortex-M4F | CryptoCell-310 TRNG + Key Vault | SoloKeys Solo 2, open-source NFC keys |
USB HID Framing
CTAP2 over USB uses the HID (Human Interface Device) protocol with a custom FIDO usage page:
- CTAPHID initialization: Allocate channel ID (CID) via
CTAPHID_INITcommand - Packet size: 64 bytes per HID report (full-speed USB)
- Fragmentation: Messages longer than 64 bytes split across multiple reports with sequence numbers
- CTAPHID commands:
CTAPHID_MSG(0x03) for CTAP2 CBOR payload,CTAPHID_CBOR(0x10) for modern CBOR framing - VID/PID: Each authenticator vendor uses unique USB VID/PID. FIDO Alliance maintains a list of certified authenticator descriptors.
Summary
FIDO2 represents a fundamental shift in authentication — from shared secrets (passwords) to public-key cryptography where the private key never leaves the authenticator. Understanding the full stack — from navigator.credentials.create() down to the CBOR bytes on the USB wire — is essential for anyone building, deploying, or testing FIDO2 authenticators.
Key takeaways:
- FIDO2 = WebAuthn (browser API) + CTAP2 (wire protocol)
- authData is the core binary structure — 37 bytes minimum, extending with attested data and extensions
- Resident keys enable usernameless login but consume authenticator flash (limited to ~25-100 slots)
- hmac-secret extension enables offline use cases: password manager unlock, disk encryption, crypto wallet security
- Attestation tells the RP "what hardware generated this key" — from none (privacy) to packed/TPM (hardware provenance)
- Client PIN Protocol v2 is the current recommended approach for PIN/UV token management