Passkeys: what the server stores, what it checks, and how to add them
Passkeys (WebAuthn) leave no secret on the server and cannot be phished: registration and login step by step, the record to store, library snippets, common mistakes.
- A passkey is a key pair. The private key never leaves the device or password manager; the server keeps only the public key, so a database leak gives an attacker nothing to log in with
- The server must verify the challenge, the origin, the rpIdHash, the user-present and user-verified flags, and the signature, then update the sign count
- Add passkeys alongside the existing login first. Password plus OTP stays as a fallback until most users have registered one
- The RP ID must equal the site's domain or a registrable parent of it. Pick it once, because a passkey made for one RP ID never works on another
Every login we have built so far stores something that can be stolen: a bcrypt hash of a password, or a phone number that receives a six-digit code. Both can be phished, because the user types a secret into whatever page is in front of them. Passkeys change the shape of the problem. The device holds a private key, the server holds only the matching public key, and the browser refuses to use the key anywhere but the site it was made for. This article explains how that works, exactly what a server stores and checks, and how to bolt it onto an existing login with a maintained library. Written from the defender’s side; snippets tested on Node 22 and Python 3.12.
What a passkey is
A passkey is the user-facing name for a WebAuthn discoverable credential: a public/private key pair created by an authenticator (the phone, the laptop’s secure chip, a password manager or a hardware key) for one website. Registering creates the pair; logging in means the server sends a random challenge and the authenticator signs it. The user unlocks the key the way they unlock the device: fingerprint, face or PIN. That unlock is user verification, and it is what makes one gesture count as two factors (something you have plus something you are or know).
Two kinds exist. A synced passkey is stored in a cloud credential manager (iCloud Keychain, Google Password Manager, a password manager app) and appears on the user’s other devices. A device-bound passkey never leaves the device it was created on; hardware security keys are the common case. From the server’s view they are the same credential, with two flags telling you which one you got.
Passwords, OTP and passkeys side by side
| Password | SMS / app OTP | Passkey | |
|---|---|---|---|
| What the server stores | hash of the secret | phone number or shared seed | public key only |
| What a database leak gives an attacker | hashes to crack offline | seeds, or numbers to SIM-swap | nothing usable for login |
| Phishable on a look-alike site | yes | yes, the code is relayed in real time | no, the signature is bound to the origin and RP ID |
| Reuse across sites | common | no | impossible, one key pair per site |
| Lost device | unaffected | locked out until the number moves | synced: restored from the credential manager; device-bound: needs another credential |
The server never holds anything that lets someone log in as the user. That single property removes credential stuffing, offline cracking and real-time phishing at once.
Registration, step by step
- The server generates a random challenge (at least 16 bytes), remembers it for this session, and sends it with the site’s RP ID, name and a user handle.
- The browser calls
navigator.credentials.create(). The authenticator asks the user to verify, creates a key pair, and returns the credential ID, the public key and some metadata. - The server checks that the challenge matches, the origin is its own, the rpIdHash is the SHA-256 of its RP ID, the credential ID is not already registered to anyone, and then stores the record.
The browser side, using the W3C API directly:
// browser — options come from your server as JSON
const cred = await navigator.credentials.create({
publicKey: {
challenge: b64urlToBuf(opts.challenge),
rp: { id: "example.com", name: "Example" },
user: { id: b64urlToBuf(opts.userId), name: "jamie@example.com", displayName: "Jamie" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }], // ES256, RS256
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
},
});
In practice you let the library do the byte conversions. With SimpleWebAuthn (@simplewebauthn/server 14.0.3, Node 20+; @simplewebauthn/browser on the page):
// server (Node) — GET /passkeys/register/options
const options = await generateRegistrationOptions({
rpName: "Example", rpID: "example.com",
userName: user.email,
attestationType: "none",
excludeCredentials: user.passkeys.map(p => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
});
session.challenge = options.challenge; // keep it server-side, single use
// browser
const attResp = await startRegistration({ optionsJSON: options });
// server — POST /passkeys/register/verify
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: session.challenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
});
if (verified) {
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
savePasskey({ id: credential.id, publicKey: credential.publicKey, counter: credential.counter,
transports: credential.transports, deviceType: credentialDeviceType, backedUp: credentialBackedUp });
}
The same two calls in Python with py_webauthn (pip install webauthn, 3.0.1 on PyPI, Python 3.10+):
# server (Python)
from webauthn import generate_registration_options, verify_registration_response, options_to_json
options = generate_registration_options(rp_id="example.com", rp_name="Example", user_name=user.email)
session["challenge"] = options.challenge # bytes
return options_to_json(options)
v = verify_registration_response(
credential=request.json, expected_challenge=session["challenge"],
expected_rp_id="example.com", expected_origin="https://example.com",
require_user_verification=True,
)
save_passkey(v.credential_id, v.credential_public_key, v.sign_count,
v.credential_device_type, v.credential_backed_up)
Login, step by step
Login is the mirror image. The server sends a fresh challenge, the browser calls navigator.credentials.get() (or startAuthentication({ optionsJSON })), the authenticator signs authenticatorData || SHA-256(clientDataJSON), and the server verifies. The W3C spec lists what the Relying Party must check; the ones that matter most:
clientData.typeiswebauthn.getclientData.challengeequals the challenge you issued, and you delete it after one useclientData.originis exactly your origin (https://example.com, with scheme and port)rpIdHashin the authenticator data is the SHA-256 of your RP ID- the user-present flag is set, and the user-verified flag is set if you require it
- the signature verifies with the stored public key
- the sign count: if the stored value or the new one is non-zero and the new one is not greater, the spec says a cloned authenticator may exist. Log it and decide. Synced passkeys usually report 0 every time, so 0 → 0 is normal
// server (Node) — POST /passkeys/login/verify
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response: asseResp,
expectedChallenge: session.challenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
credential: { id: pk.id, publicKey: pk.publicKey, counter: pk.counter, transports: pk.transports },
requireUserVerification: true,
});
if (verified) { pk.counter = authenticationInfo.newCounter; createSession(pk.userId); }
For a username-less login, omit allowCredentials and pass useBrowserAutofill: true with an <input autocomplete="username webauthn">; the browser shows the user’s passkeys in the autofill list. The server finds the record by the credential ID in the response and uses that record’s user.
Worked example: one user’s stored record
Everything the server keeps for one passkey, for one user, with real sizes:
| Field | Example | Size |
|---|---|---|
user_handle | 16 random bytes, base64url Zm9vYmFyYmF6cXV4MTIz | 16 bytes. Never the email: it is shown to no one and must not change |
credential_id | base64url, vJhG… | 16–64 bytes is typical; the spec caps it at 1,023 bytes, so a VARCHAR(1400) or BYTEA with a unique index |
public_key | COSE-encoded bytes | ES256 key ≈ 77 bytes, RS256 ≈ 270 bytes; store as BYTEA |
sign_count | 0 | 32-bit unsigned integer |
transports | ["internal","hybrid"] | short JSON array; send it back in allowCredentials so the browser picks the right path |
device_type / backed_up | multiDevice / true | synced passkey; singleDevice / false means device-bound |
name, created_at, last_used_at | “iPhone”, timestamps | for the user’s own list, so they can delete one |
Plus one site-wide setting: the RP ID (example.com) and the list of accepted origins. Next to it sits a 60-byte bcrypt hash an attacker can crack offline; the passkey row is bigger and worthless to steal.
Recovery and fallback
Do not make passkeys the only door on day one. The order that has worked:
- Let logged-in users add a passkey from their security settings. Keep password + OTP as it is.
- Show the passkey button on the login page; fall back to the old form when
navigator.credentialsis missing or the user cancels. - Allow several passkeys per account (phone, laptop, a hardware key in a drawer) and show the list with a delete button, so a lost device can be removed and a new one registered.
- Keep a recovery path that does not depend on the device: a password reset by email, or printed recovery codes. Treat it as the weakest link and rate-limit it.
Only when most active accounts have a passkey do you consider making it the default and demoting the password to recovery.
Support in 2026
WebAuthn has been in every major browser since 2021 (MDN: Baseline). Synced passkeys ship with iOS/iPadOS 16+, macOS 13+, Android 9+ and ChromeOS 129+; third-party password managers can hold them on iOS 17+, Android 14+ and Windows 11 25H2+. Scanning a QR code to sign in on a desktop with a phone (hybrid transport) works in Chrome, Edge, Firefox and Safari. Figures from passkeys.dev as of September 2026; check the current docs before promising a specific version to users.
Common mistakes
- Wrong RP ID on subdomains. A passkey made with RP ID
app.example.comcannot be used ataccount.example.com. If users may log in on several subdomains, set the RP ID toexample.comfrom the start. The RP ID must equal the origin’s domain or be a registrable suffix of it;comis not allowed, and neither is a domain you do not serve. - Not checking the origin. The signature is bound to
clientData.origin; if the server does not compare it against its own origin, a proxying look-alike site can relay a valid signature. Always passexpectedOrigin. - Skipping user verification. With
userVerification: "preferred"and no check on the UV flag, a stolen unlocked phone logs in with a tap. Decide what you require and check the flag on the server, not just in the options. - Reusing a challenge or keeping it in the browser. One challenge, one ceremony, stored server-side.
- No re-registration path. A user with one device-bound key who loses the device must be able to prove identity another way and register again.
- Using the email as the user handle. It leaks to anyone with the device and breaks when the email changes.
Sources
- W3C Web Authentication Level 3, Recommendation, 25 Aug 2026 — RP ID rule, §6.1.1 signature counter, §7.1 registration record
- W3C Web Authentication Level 2, §7.2 Verifying an Authentication Assertion — the ordered list of checks
- FIDO Alliance: Passkeys — synced vs device-bound, phishing resistance
- passkeys.dev: Device Support — OS and browser matrix, updated Sep 2026
- MDN: Web Authentication API —
navigator.credentials.create()/get() - SimpleWebAuthn server docs and py_webauthn docs — function names and stored fields
FAQ
Can a passkey be phished like a one-time code?
No. The browser signs the challenge together with the origin it was given, and the key only works for the RP ID it was created for. A look-alike site gets a signature the real server rejects, and there is no code the user can type into the wrong page.
Do I still need to store password hashes once passkeys are in?
At first, yes. Keep the existing login as a fallback and let users add a passkey. Once most accounts have one, you can make passkeys the default and move passwords to recovery only.
What is the difference between a synced and a device-bound passkey?
A synced passkey lives in a credential manager such as iCloud Keychain, Google Password Manager or a third-party password manager and follows the user to new devices. A device-bound one, for example on a hardware security key, never leaves that device. The server sees which it got through the backup flags.
Commands and settings were tested on sample systems. Try them on a test machine before production.