Articles › Security

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.

Intermediate 9 min read October 21, 2026

What you will learn
  • 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

PasswordSMS / app OTPPasskey
What the server storeshash of the secretphone number or shared seedpublic key only
What a database leak gives an attackerhashes to crack offlineseeds, or numbers to SIM-swapnothing usable for login
Phishable on a look-alike siteyesyes, the code is relayed in real timeno, the signature is bound to the origin and RP ID
Reuse across sitescommonnoimpossible, one key pair per site
Lost deviceunaffectedlocked out until the number movessynced: 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.

User's deviceprivate keyServerpublic key1 send a challenge2 unlock with finger, sign3 send the signature4 verify with public keyno password crosses the network; a fake site gets a useless signature
The server keeps only the public key; a leak cannot log anyone in

Registration, step by step

  1. 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.
  2. 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.
  3. 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.type is webauthn.get
  • clientData.challenge equals the challenge you issued, and you delete it after one use
  • clientData.origin is exactly your origin (https://example.com, with scheme and port)
  • rpIdHash in 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:

FieldExampleSize
user_handle16 random bytes, base64url Zm9vYmFyYmF6cXV4MTIz16 bytes. Never the email: it is shown to no one and must not change
credential_idbase64url, 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_keyCOSE-encoded bytesES256 key ≈ 77 bytes, RS256 ≈ 270 bytes; store as BYTEA
sign_count032-bit unsigned integer
transports["internal","hybrid"]short JSON array; send it back in allowCredentials so the browser picks the right path
device_type / backed_upmultiDevice / truesynced passkey; singleDevice / false means device-bound
name, created_at, last_used_at“iPhone”, timestampsfor 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:

  1. Let logged-in users add a passkey from their security settings. Keep password + OTP as it is.
  2. Show the passkey button on the login page; fall back to the old form when navigator.credentials is missing or the user cancels.
  3. 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.
  4. 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.com cannot be used at account.example.com. If users may log in on several subdomains, set the RP ID to example.com from the start. The RP ID must equal the origin’s domain or be a registrable suffix of it; com is 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 pass expectedOrigin.
  • 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

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.