Security Guide · Companion to Passkeys

Implement passkeys in a web application.

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.

WebAuthn / FIDO2 Windows Hello Touch ID & Face ID YubiKey Discoverable Credentials
The Core Idea

Two ceremonies, one relying party.

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.

Register — create()

The authenticator generates a key pair, keeps the private key, and returns the public key to your server to store against the user.

Authenticate — get()

The server sends a random challenge. The device signs it with the private key after a biometric or PIN. No secret is transmitted.

Verify — server

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.

OPTION 01 Built Into The Operating System

Platform authenticators — already on the device.

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.

Browsernavigator.credentials
Platform authenticatorWindows Hello · Touch ID
Secure elementTPM 2.0 · Secure Enclave
private key never leaves this boundary
Windows HelloWindows 10/11 · TPM 2.0
Touch ID / Face IDmacOS · iOS · iPadOS
Androidfingerprint · screen lock
  • Zero hardware cost — the sensor and secure chip are already in the laptop or phone the user owns.
  • Fast enrolment — one biometric prompt registers a passkey; no QR codes or shared secrets change hands.
  • Hardware-backed keys — the private key lives in the TPM or Secure Enclave, gated by the biometric.
  • Syncable — platform passkeys can sync across the user's own devices via the OS keychain (see below).
  • Built for autofill — request discoverable credentials to offer passkeys in the browser's sign-in prompt.
Platform vs. roaming

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.

What each operating system gives you.

The WebAuthn API is identical across platforms — these are the practical details of the built-in authenticators your users will meet most often.

WINDOWS

Windows Hello

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.

attachment: "platform" · userVerification: "required" · backed by TPM 2.0
MACOS · IOS

Touch ID & Face ID

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.

attachment: "platform" · synced via iCloud Keychain · Secure Enclave
OPTION 02 Roaming Authenticators

Hardware security keys — a YubiKey you carry.

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.

Platform Authenticator

Built into the device

  • Bound to one laptop or phone
  • May sync via the OS keychain
  • Biometric or device PIN
  • Best for everyday consumer sign-in
Lowest friction — the user already has it.
Roaming Security Key

A key you carry

  • Portable across every machine
  • Device-bound — never leaves the token
  • Touch for presence, PIN for verification
  • Best for high-assurance and shared devices
Highest assurance — nothing to sync, nothing to phish.

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.

The registration ceremony.

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.

  1. 01

    Server issues creation options

    Generate a random, single-use challenge and keep it server-side. Declare the relying party, the accepted algorithms, and how the authenticator should behave.

  2. 02

    Browser runs create()

    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.

  3. 03

    Server verifies and stores

    Confirm the challenge, type and origin in clientDataJSON, then persist the public key, credential ID and initial signature counter against the user.

    // 1 — SERVER builds the creation options and stores the challenge const options = { challenge: randomBytes(32), // fresh, single-use, kept server-side rp: { id: "example.com", name: "entityOS" }, user: { id: userId, name: email, displayName: fullName }, pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256 (preferred) { type: "public-key", alg: -257 } // RS256 (broad fallback) ], authenticatorSelection: { residentKey: "preferred", // discoverable -> usernameless sign-in userVerification: "required" // biometric or PIN, not just presence }, excludeCredentials: existingCreds, // stop re-registering the same key timeout: 60000, attestation: "none" // ask for "direct" only if you verify it }; // 2 — BROWSER runs the ceremony (user touches sensor / enters PIN) const cred = await navigator.credentials.create({ publicKey: options }); // 3 — SERVER verifies clientDataJSON (type, challenge, origin), parses the // attestationObject, then stores the public key + credentialId + signCount.

Only request attestation: "direct" if you actually validate the attestation statement — otherwise "none" keeps the flow simple and private.

Side By Side

Windows Hello · Touch ID · YubiKey.

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
AttachmentPlatformPlatformCross-platform (roaming)
Where the key livesTPM 2.0Secure EnclaveOn the security key
User verificationFace · fingerprint · PINTouch ID · Face IDTouch + optional PIN
Portable across machinesNoNoYes
Syncs across devicesMicrosoft account*iCloud KeychainNo — device-bound
Discoverable credentialsYesYesYes (limited slots)
TransportInternalInternal · hybridUSB · NFC · Lightning
Best suited toEveryday desktopApple ecosystemHigh 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.

The authentication ceremony.

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.

  1. 01

    Server issues request options

    A fresh challenge again, scoped to your rpId. Supply the user's known credential IDs, or omit them for discoverable sign-in.

  2. 02

    Browser runs get()

    The device asks for the biometric or PIN and signs the challenge. The private key stays put; only a signature crosses the wire.

  3. 03

    Server verifies the signature

    Verify against the stored public key, check origin, rpId hash and the user-verified flag, and confirm the signature counter has advanced.

    // 1 — SERVER builds request options with a fresh challenge const options = { challenge: randomBytes(32), rpId: "example.com", allowCredentials: userCreds, // omit entirely for discoverable creds userVerification: "required", timeout: 60000 }; // 2 — BROWSER signs the challenge locally; private key never leaves the device const assertion = await navigator.credentials.get({ publicKey: options }); // 3 — SERVER looks up the stored public key by credentialId and verifies the // signature over authenticatorData + hash(clientDataJSON). Then it checks // the origin, the rpId hash, the userVerified flag, and that signCount // has advanced (guards against a cloned device-bound authenticator).

For passkey autofill, call get() with mediation: "conditional" so the browser can offer credentials in the sign-in field.

Get The Details Right

Six things that decide whether it works.

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.

01
RP ID and origin
The 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.
Foundational
02
User verification
"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".
Assurance
03
Discoverable credentials
Set residentKey: "preferred" to store the credential on the authenticator so users can sign in with no username. On hardware keys these consume finite slots.
Usernameless
04
excludeCredentials
Pass the user's existing credential IDs at registration so the same authenticator can't enrol twice — the browser will refuse an already-registered device.
Hygiene
05
Algorithms
List ES256 (-7) first for the widest, most efficient support, and include RS256 (-257) as a fallback for older authenticators. Never assume a single algorithm.
Compatibility
06
Timeouts and errors
Set a sensible 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.
Resilience

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.

Before You Ship

A production checklist.

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.

HTTPS and RP ID

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.

Server-side challenges

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.

Signature counter

Store and compare the counter on each sign-in for device-bound keys. Tolerate a zero counter from synced passkeys rather than rejecting them.

Recovery and fallback

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.

Sync vs. device-bound

Decide whether synced passkeys meet your assurance needs. Where they don't, require a device-bound roaming key for the sensitive path.

Test across platforms

Exercise the flow on Windows Hello, Touch ID, Android and a YubiKey. Behaviour around UV, discoverable credentials and sync differs in practice.

The entityOS Position

Passkeys on the public web; the same idea, deeper in.

PUBLIC WEB

Passkeys

Asymmetric credentials bound to your domain, verified by the browser. The strongest consumer sign-in available today — and the subject of this guide.

challenge -> sign -> verify
CLOSED NETWORKS

mTLS + X.509

Mutual TLS with client certificates is WebAuthn's enterprise cousin: asymmetric crypto, identity proven by signature, the trust anchor inside your own perimeter.

cert -> handshake -> verify
SELF-SOVEREIGN

KERI / ACDC

Self-sovereign identity removes the central custodian entirely. The entity holds and presents its own verifiable credential — no platform to depend on.

key event -> present -> verify

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.