FIDO2 vs WebAuthn is a question about layers rather than a choice. WebAuthn (W3C Web Authentication) defines the JavaScript API that relying parties call and the signed data formats they verify. CTAP (FIDO Client to Authenticator Protocol) defines how a browser or operating system talks to an external authenticator over USB, NFC, Bluetooth or the hybrid QR-code transport. FIDO2 is the FIDO Alliance's umbrella name, introduced in 2018, for WebAuthn plus CTAP 2. Passkeys are FIDO credentials created through these standards.
At a glance
| WebAuthn | CTAP | FIDO2 | |
|---|---|---|---|
| What it is | Browser API and data formats | Wire protocol to roaming authenticators | Umbrella term for both |
| Publisher | W3C | FIDO Alliance | FIDO Alliance |
| Current version | Level 3, W3C Recommendation 25 August 2026 | CTAP 2.3, Proposed Standard 26 February 2026 (2.2: July 2025) | WebAuthn Level 3 with CTAP 2.3 |
| Earlier versions | Level 1 (March 2019), Level 2 (April 2021) | CTAP 2.0, 2.1 (2021); CTAP1 is U2F | U2F (2014) is the predecessor |
| Who implements it | Browsers and platforms (API), relying parties (server checks) | Browsers, operating systems, security keys, phones | Everyone in the chain |
| Between | Web page and browser; server and authenticator data | Client platform and authenticator | Relying party to authenticator |
| Encoding | JavaScript objects, clientDataJSON, CBOR attestation objects | CTAP2 canonical CBOR commands such as authenticatorGetAssertion (0x02) | Both |
| Transports | Not applicable | USB HID, NFC, Bluetooth Low Energy, hybrid (QR code, proximity check, tunnel) | Both |
| Main entry points | navigator.credentials.create() and .get() | authenticatorMakeCredential (0x01), authenticatorGetInfo (0x04) | Not applicable |
Who does what during a sign-in
- The website calls
navigator.credentials.get()with a challenge and its relying party ID. This is WebAuthn. - The browser builds
clientDataJSON(type, challenge, origin, cross-origin flag), hashes it and asks an authenticator to sign. For a built-in platform authenticator (Touch ID, Windows Hello, Android) this goes through operating system APIs; for a security key or a phone it goes over CTAP as anauthenticatorGetAssertioncommand in CBOR. - The authenticator checks user presence and, if requested, user verification, then signs its authenticator data concatenated with the client data hash. The response travels back over CTAP or the platform API.
- The browser returns the bytes to the page, and the server verifies them as WebAuthn §7.2 specifies.
The script below parses the official WebAuthn Level 3 test vector §16.2, which contains a registration and an authentication for an ES256 credential, and verifies the signature (Node.js 26, cbor-x 1.6.0). The authData and the signature are what the authenticator produced; clientDataJSON is what the browser produced.
import { createHash, createPublicKey, verify } from "node:crypto";
import { decode } from "cbor-x";
// WebAuthn Level 3 §16.2 test vector: ES256 credential, "none" attestation
const hex = (s) => Buffer.from(s, "hex");
const attestationObject = hex("a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b559000000008446ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220");
const authenticatorData = hex("bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000");
const clientDataJSON = hex("7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d");
const signature = hex("3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87");
const sha256 = (b) => createHash("sha256").update(b).digest();
const flagNames = { 0x01: "UP", 0x04: "UV", 0x08: "BE", 0x10: "BS", 0x40: "AT", 0x80: "ED" };
const flags = (b) => Object.entries(flagNames).filter(([bit]) => b & bit).map(([, n]) => n).join(" ");
// Registration: authenticator data produced by the authenticator (CTAP authenticatorMakeCredential)
const { fmt, authData } = decode(attestationObject);
const credIdLen = authData.readUInt16BE(53);
const coseKey = decode(authData.subarray(55 + credIdLen));
console.log("attestation format:", fmt);
console.log("rpIdHash == SHA-256('example.org'):", authData.subarray(0, 32).equals(sha256("example.org")));
console.log("flags:", flags(authData[32]), "| signCount:", authData.readUInt32BE(33));
console.log("AAGUID:", authData.subarray(37, 53).toString("hex"), "| credential ID bytes:", credIdLen);
console.log("COSE key: kty", coseKey[1], "alg", coseKey[3], "crv", coseKey[-1]);
// Authentication: verify the assertion with the registered public key
const publicKey = createPublicKey({ key: { kty: "EC", crv: "P-256",
x: Buffer.from(coseKey[-2]).toString("base64url"), y: Buffer.from(coseKey[-3]).toString("base64url") }, format: "jwk" });
console.log("\nclientDataJSON (built by the browser):", clientDataJSON.toString());
console.log("assertion flags:", flags(authenticatorData[32]));
const ok = verify("sha256", Buffer.concat([authenticatorData, sha256(clientDataJSON)]), publicKey, signature);
console.log("signature over authenticatorData || SHA-256(clientDataJSON):", ok ? "valid" : "invalid");
attestation format: none
rpIdHash == SHA-256('example.org'): true
flags: UP BE BS AT | signCount: 0
AAGUID: 8446ccb9ab1db374750b2367ff6f3a1f | credential ID bytes: 32
COSE key: kty 2 alg -7 crv 1
clientDataJSON (built by the browser): {"type":"webauthn.get","challenge":"OcDnUhQXulTUPo3JUXT0I97pvzzYBP9tZchXyav01Ag","origin":"https://example.org","crossOrigin":false}
assertion flags: UP BE BS
signature over authenticatorData || SHA-256(clientDataJSON): valid
The flags show user presence (UP), a credential that is eligible for backup and backed up (BE, BS: a synced passkey) and attested credential data (AT) at registration. The COSE key (key type 2 = EC2, algorithm −7 = ES256, curve 1 = P-256) is the format both specifications share; the WebAuthn inspector decodes the same structures from a real browser response.
From U2F to passkeys
FIDO U2F (2014) gave security keys a simple second-factor protocol, now called CTAP1, and a browser API. WebAuthn replaced that API in 2018–2019 (Chrome 67, Firefox 60, Safari 13), and CTAP 2 added PINs, built-in user verification and discoverable credentials, so a key could replace the password rather than supplement it. Chrome removed the old U2F JavaScript API in version 104 (2022); credentials registered through it still work in WebAuthn through the appid extension.
The 2022 announcement of synced passkeys by Apple, Google and Microsoft did not need a new protocol: a passkey is a discoverable credential with the backup flags set, created and used through the same WebAuthn API. CTAP 2.2 (July 2025) standardized the hybrid transport, which lets a phone act as the authenticator for a nearby computer after scanning a QR code, along with PIN complexity policies; CTAP 2.3 (February 2026) is an incremental revision, for example letting authenticators report which configuration commands they support.
Attestation and certification
At registration an authenticator can include an attestation statement signed by a key shared across a batch of devices, and an AAGUID identifying the model. WebAuthn defines the statement formats (packed, tpm, android-key, apple, fido-u2f, none); the FIDO Alliance certifies authenticators and publishes their metadata, including attestation roots and certification levels, in the FIDO Metadata Service. Most consumer sites should request no attestation and accept any authenticator; enterprises that must allow only certified hardware keys request direct or enterprise attestation and check it against the metadata, which the YubiKey attestation verifier and AAGUID lookup illustrate.
What to implement
- Website or web app: WebAuthn in the browser and a server library that implements the §7 verification steps.
- Native iOS or Android app: the platform passkey APIs (AuthenticationServices, Credential Manager), which return WebAuthn-format data bound to your web domain through associated domains or Digital Asset Links.
- Enterprise allowing only hardware keys: WebAuthn with attestation, checked against FIDO Metadata Service entries.
- Security key, phone or OS vendor: CTAP for roaming authenticators, FIDO certification for the product.
- Procurement: ask for FIDO certification level, supported CTAP version and transports, and the WebAuthn features you rely on, rather than "FIDO2 support" alone.
Migration notes
- Replacing U2F registrations: keep sending the old App ID through the
appidextension at sign-in, and register a new WebAuthn credential when users next sign in. - Adopting passkeys: request
residentKey: "required"anduserVerification: "preferred"or"required", and store the backup flags. - Upgrading to Level 3 features one by one: conditional creation,
getClientCapabilities(), the signal methods and related origins all degrade gracefully when unsupported. - Testing: exercise platform authenticators, a USB or NFC key and the hybrid flow from a phone in the passkey sandbox before launch.
References
- W3C Web Authentication Level 3 (§6.1 authenticator data, §7 relying party operations, §16 test vectors)
- FIDO Client to Authenticator Protocol 2.3
- FIDO Client to Authenticator Protocol 2.2 (§6 commands, §11 transport bindings)
- FIDO Metadata Service
- RFC 9052, COSE structures and process and RFC 9053, COSE algorithms