Security Guide · Companion to Passkeys
A practical walkthrough of adding passkeys — WebAuthn credentials — to a web app. The same two API calls cover the fingerprint reader built into Windows, Touch ID on a Mac, and a hardware key like a YubiKey. This guide shows the ceremonies, the platform support, and the details that decide whether it works.
A passkey is a public/private key pair bound to your site's domain. Your server holds the public key; the private key stays in the user's device or security key. Everything reduces to two browser calls — one to register, one to sign in — against a relying party you configure once.
The authenticator generates a key pair, keeps the private key, and returns the public key to your server to store against the user.
The server sends a random challenge. The device signs it with the private key after a biometric or PIN. No secret is transmitted.
Your server checks the signature against the stored public key and confirms the origin. The browser guarantees the credential only works on your domain.
The same WebAuthn code path serves a laptop's fingerprint reader and a USB security key alike.
Modern Windows and Apple machines ship with a hardware-backed authenticator and a biometric sensor. Set authenticatorAttachment: "platform" and the user signs in with the sensor already in front of them — nothing to buy, nothing to carry.
A platform authenticator is tied to one device (though it may sync). If a user needs to sign in from a machine that isn't theirs, pair it with a roaming key — or use cross-device sign-in with a nearby phone.
The WebAuthn API is identical across platforms — these are the practical details of the built-in authenticators your users will meet most often.
Available on Windows 10 (version 1903+) and Windows 11 in Edge, Chrome and Firefox. The key is stored in the machine's TPM 2.0; the user verifies with facial recognition, a fingerprint, or a Hello PIN. Discoverable credentials are supported, and recent Windows 11 builds add passkey management and sync through a Microsoft account or a third-party manager.
On a Mac (Apple silicon or a T2 chip) the key lives in the Secure Enclave and is unlocked with Touch ID; on iPhone and iPad it is Face ID or Touch ID. From macOS Ventura and iOS 16 onward, passkeys sync across the user's Apple devices through iCloud Keychain. A user on a non-Apple machine can sign in by scanning a QR code and approving on a nearby iPhone.
A roaming authenticator is a separate device — a YubiKey or similar FIDO2 key — that plugs in over USB or taps over NFC. The private key never leaves the token, it doesn't sync anywhere, and it works on any machine. Set authenticatorAttachment: "cross-platform" to steer the user toward one.
A YubiKey 5 speaks FIDO2 / CTAP2 over USB-A, USB-C, NFC and Lightning. It can hold discoverable credentials for usernameless sign-in, within a limited slot count — so treat those slots as a finite resource.
Registering a passkey is three moves: the server issues options with a fresh challenge, the browser runs create(), and the server stores the returned public key. The same code registers a platform authenticator or a hardware key — the only difference is which one you steer the user toward.
Generate a random, single-use challenge and keep it server-side. Declare the relying party, the accepted algorithms, and how the authenticator should behave.
The user touches the sensor or the key. The authenticator makes the key pair and returns an attestation object with the new public key and credential ID.
Confirm the challenge, type and origin in clientDataJSON, then persist the public key, credential ID and initial signature counter against the user.
Only request attestation: "direct" if you actually validate the attestation statement — otherwise "none" keeps the flow simple and private.
All three are WebAuthn authenticators your code treats identically. They differ in where the key lives, whether it travels, and what they are best suited to.
| Property | Windows Hello | Touch ID / Face ID | YubiKey |
|---|---|---|---|
| Attachment | Platform | Platform | Cross-platform (roaming) |
| Where the key lives | TPM 2.0 | Secure Enclave | On the security key |
| User verification | Face · fingerprint · PIN | Touch ID · Face ID | Touch + optional PIN |
| Portable across machines | No | No | Yes |
| Syncs across devices | Microsoft account* | iCloud Keychain | No — device-bound |
| Discoverable credentials | Yes | Yes | Yes (limited slots) |
| Transport | Internal | Internal · hybrid | USB · NFC · Lightning |
| Best suited to | Everyday desktop | Apple ecosystem | High assurance |
*Sync behaviour depends on OS version and account configuration. Device-bound keys report an advancing signature counter; synced passkeys often report zero — handle both.
Sign-in mirrors registration. The server issues a challenge, the browser runs get(), and the server verifies the signature against the stored public key. Leave allowCredentials empty to let a discoverable credential drive a usernameless sign-in.
A fresh challenge again, scoped to your rpId. Supply the user's known credential IDs, or omit them for discoverable sign-in.
The device asks for the biometric or PIN and signs the challenge. The private key stays put; only a signature crosses the wire.
Verify against the stored public key, check origin, rpId hash and the user-verified flag, and confirm the signature counter has advanced.
For passkey autofill, call get() with mediation: "conditional" so the browser can offer credentials in the sign-in field.
WebAuthn fails closed: get one of these wrong and the credential simply won't surface. These are the settings that trip up most first implementations.
rp.id must be your registrable domain or a parent of it, and the page must be served over HTTPS. Register and authenticate on the same origin — localhost is the only exception, for development."required" forces a biometric or PIN; "preferred" allows presence-only where UV isn't available. For a passkey that stands alone as a factor, prefer "required".residentKey: "preferred" to store the credential on the authenticator so users can sign in with no username. On hardware keys these consume finite slots.-7) first for the widest, most efficient support, and include RS256 (-257) as a fallback for older authenticators. Never assume a single algorithm.timeout and handle the abort gracefully — users cancel, unplug keys, or pick the wrong device. A clear retry path matters more than any single setting.Because verification is done by the browser and your server — not by a human reading a URL — getting these settings right is the whole security model. There is no secret to leak and nothing to phish once the origin binding holds.
Passkeys are only as strong as the flow around them. These are the checks worth making before you turn the feature on for real users.
Serve every page over HTTPS and pin the RP ID to your registrable domain. A mismatch here is the single most common cause of a credential never appearing.
Generate every challenge on the server, keep it single-use, and bind it to the session. Never let the client supply or reuse a challenge.
Store and compare the counter on each sign-in for device-bound keys. Tolerate a zero counter from synced passkeys rather than rejecting them.
Let users enrol more than one authenticator and design a recovery path. A lost device shouldn't mean a locked account — but recovery is the flow attackers target.
Decide whether synced passkeys meet your assurance needs. Where they don't, require a device-bound roaming key for the sensitive path.
Exercise the flow on Windows Hello, Touch ID, Android and a YubiKey. Behaviour around UV, discoverable credentials and sync differs in practice.
Asymmetric credentials bound to your domain, verified by the browser. The strongest consumer sign-in available today — and the subject of this guide.
Mutual TLS with client certificates is WebAuthn's enterprise cousin: asymmetric crypto, identity proven by signature, the trust anchor inside your own perimeter.
Self-sovereign identity removes the central custodian entirely. The entity holds and presents its own verifiable credential — no platform to depend on.
The principle is the same at every layer: the fewer parties hold the secret, and the more the verification is done by code rather than by people, the stronger the system.
Passkeys are where that principle meets the public web. Start here, and the rest of the model follows.