Skip to content
ecdsa.com

FIDO2 vs WebAuthn vs CTAP: how the passkey standards fit together

They are not alternatives. WebAuthn is the W3C API that websites call, CTAP is the FIDO protocol between the device and a security key or phone, and FIDO2 is the name for the two together.

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

WebAuthnCTAPFIDO2
What it isBrowser API and data formatsWire protocol to roaming authenticatorsUmbrella term for both
PublisherW3CFIDO AllianceFIDO Alliance
Current versionLevel 3, W3C Recommendation 25 August 2026CTAP 2.3, Proposed Standard 26 February 2026 (2.2: July 2025)WebAuthn Level 3 with CTAP 2.3
Earlier versionsLevel 1 (March 2019), Level 2 (April 2021)CTAP 2.0, 2.1 (2021); CTAP1 is U2FU2F (2014) is the predecessor
Who implements itBrowsers and platforms (API), relying parties (server checks)Browsers, operating systems, security keys, phonesEveryone in the chain
BetweenWeb page and browser; server and authenticator dataClient platform and authenticatorRelying party to authenticator
EncodingJavaScript objects, clientDataJSON, CBOR attestation objectsCTAP2 canonical CBOR commands such as authenticatorGetAssertion (0x02)Both
TransportsNot applicableUSB HID, NFC, Bluetooth Low Energy, hybrid (QR code, proximity check, tunnel)Both
Main entry pointsnavigator.credentials.create() and .get()authenticatorMakeCredential (0x01), authenticatorGetInfo (0x04)Not applicable

Who does what during a sign-in

  1. The website calls navigator.credentials.get() with a challenge and its relying party ID. This is WebAuthn.
  2. 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 an authenticatorGetAssertion command in CBOR.
  3. 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.
  4. 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.

Parse and verify the WebAuthn Level 3 test vector §16.2
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");
Output
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

  1. Replacing U2F registrations: keep sending the old App ID through the appid extension at sign-in, and register a new WebAuthn credential when users next sign in.
  2. Adopting passkeys: request residentKey: "required" and userVerification: "preferred" or "required", and store the backup flags.
  3. Upgrading to Level 3 features one by one: conditional creation, getClientCapabilities(), the signal methods and related origins all degrade gracefully when unsupported.
  4. Testing: exercise platform authenticators, a USB or NFC key and the hybrid flow from a phone in the passkey sandbox before launch.

References

All comparisons