FIDO2 security key vs passkey for AAL3
Security key rollouts involve three separate decisions: how the key is used, how the server identifies it and what the user sees when they touch it. Most implementation problems start when these get mixed up. The numbers
Security key rollouts involve three separate decisions: how the key is used, how the server identifies it and what the user sees when they touch it. Most implementation problems start when these get mixed up. The numbers below come from real registrations in the Passkeys Debugger, so you can reproduce them with your own key.
Two ways to use a security key
The key does not decide how it is used. The relying party does, through the options it sends to navigator.credentials.create().
| Mode | residentKey |
userVerification |
User experience | Storage on the key |
|---|---|---|---|---|
| Second factor (U2F-style) | discouraged |
discouraged |
Password, then touch | Nothing, the private key is wrapped into the credential ID |
| Passkey on the key | required |
required |
Touch plus key PIN | One slot per account |
// Security key as second factor (U2F-style)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { id: "example.com", name: "Example" },
user: { id: userId, name: "[email protected]", displayName: "Jane" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
authenticatorSelection: {
residentKey: "discouraged", // "required" for a passkey on the key
userVerification: "discouraged" // "required" for a passkey on the key
},
attestation: "direct",
hints: ["security-key"]
}
});
Both modes can reach NIST AAL3 on the authenticator side. AAL3 applies to the whole authentication process though, so FIPS 140 validation, replay resistance and reauthentication limits still apply on top.
Stick to clear values. With "preferred" the browser decides what gets created, and you have to check the user verified flag after every registration and classify the key yourself.
Why attestation decides AAL3
Synced passkeys do not reach AAL3 because their keys are exportable by design. A password manager extension can intercept create() and save a synced passkey in place of the hardware key. Attestation is the only reliable signal that the server got real hardware.
The format is not your choice. It depends on the key and on the protocol the browser uses:
-
Chrome on macOS: 21 of 26 U2F-style registrations returned
fido-u2f. -
Safari on macOS: 9 of 11 returned
packed. -
All U2F-style registrations: 23 of 54 arrived as
fido-u2f. -
Passkey-style registrations: 362 of 368 were
packed.
A verifier that only handles packed rejects genuine keys.
Identifying the key
-
packed: look up the model by its AAGUID. -
fido-u2f: the AAGUID is all zeros. Compute the key ID yourself:
// fido-u2f: certificate key ID for the FIDO Metadata Service lookup
import { X509Certificate, createHash } from "node:crypto";
const cert = new X509Certificate(attStmt.x5c[0]);
const spki = cert.publicKey.export({ type: "spki", format: "der" });
// SHA-1 over the bare public key (subjectPublicKey), not the full SPKI structure
const rawKey = spki.subarray(spki.length - 65); // uncompressed P-256 point
const keyId = createHash("sha1").update(rawKey).digest("hex");
// match keyId against attestationCertificateKeyIdentifiers in the MDS entries
The certificate serial identifies a batch shared by many thousands of keys of the same model, so attestation does not make a user trackable.
What "FIDO Certified" means
It is a FIDO Metadata Service lookup. A key passes when:
- Listed: the model is in the MDS, by AAGUID or certificate key ID.
- Chain: the attestation chain reaches the root listed for that model.
-
Certified: a status of
FIDO_CERTIFIEDor_L1up to_L3plus. -
Clean: the latest relevant status is not
REVOKED,ATTESTATION_KEY_COMPROMISEorUSER_KEY_REMOTE_COMPROMISE.
In the debugger data 365 of 448 attested security key registrations (81%) pass "L1 or higher". Require L1, not L2: the same YubiKey model appears as L1 and L2 depending on firmware, and L2 would have rejected another 40% of registrations, most of them genuine YubiKeys. L1 also covers software authenticators, so for AAL3 also check that keyProtection in the metadata says hardware or secure element.
Filtering synced passkeys
-
Attestation plus MDS: password managers usually send attestation
"none"and are not listed as certified hardware. This is the only layer that proves hardware. - Backup eligible flag: synced passkeys carry BE=1, credentials that live only on a hardware key BE=0. Rejecting BE=1 filters out most password manager passkeys.
-
security-keyhint: in our test the 1Password extension did not intercept the registration with this hint. Treat it as better UX, not as protection.
The PIN prompt is not under your control
userVerification: "discouraged" is a request:
-
CTAP 2.0 keys with a PIN set want the PIN for every CTAP2 registration. Chrome avoids that by falling back to U2F (CTAP1), one reason it returns
fido-u2fso often. -
CTAP 2.1 keys support
makeCredUvNotRqdand create a non-discoverable credential over CTAP2 without a PIN. YubiKeys with firmware 5.7 and later belong here. - Windows owns the security key. Chrome, Edge and Firefox hand the request to the Windows WebAuthn API, so the U2F fallback is not available and a CTAP 2.0 key with a PIN will typically ask for it.
-
credProtect: Chrome protects every discoverable credential with level 2. The combination
residentKey: "required"plususerVerification: "preferred"gets level 3. With level 3, or level 2 without a credential ID in the request, the key itself demands the PIN at login.
Buy CTAP 2.1 keys if you issue them yourself, and test Windows, macOS, Linux, iOS and Android with Chrome, Safari, Edge and Firefox, at registration and login, with one key that has a PIN and one that does not.
Registration checklist
-
Request: U2F-style or passkey options,
attestation: "direct"and thesecurity-keyhint. - Synced passkeys: reject BE=1.
-
Attestation: verify
packedandfido-u2fup to the manufacturer root. For Yubico, trust both the older root and the newer one used from firmware 5.7. -
Metadata: certified L1 or higher, no revocation, hardware
keyProtection. - Storage: keep model, format, AAGUID or key ID, certification level and registration date per key.
- Operations: reload the MDS monthly and re-check stored keys against new revocations.
- Recovery: let users register a second key and define the lost-key path before launch.
The full article with screenshots, the PIN prompt data per browser and the AAL3 details is on the Corbado blog.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.