All posts
Security

How Passkeys Actually Work: A WebAuthn Walkthrough for Developers

Passkeys replace passwords with origin-scoped key pairs. Here is what happens during registration and sign-in, how to read the authenticator data flags, and which server checks you cannot skip.

LeepCast7 min read
How Passkeys Actually Work: A WebAuthn Walkthrough for Developers

Passwords fail in two predictable ways. People reuse them, so one breached database unlocks accounts elsewhere, and people type them into convincing fake login pages. One-time codes help with the first problem but not the second: a phishing page can ask for the code and relay it to the real site within seconds. Passkeys are the first widely deployed login method designed to remove both failure modes at once, and the reason they work is in the protocol underneath them, WebAuthn.

This explainer walks through what a passkey actually is, what happens during registration and sign-in, what the server must check, and the handful of fields that teams most often misread. The goal is that by the end, the moving parts of a passkey integration feel like ordinary public-key cryptography with careful bookkeeping rather than magic.

What a passkey is, in protocol terms

The passkeys. dev glossary describes a passkey as the everyday name for a FIDO2/WebAuthn discoverable credential. Two words in that definition matter. A WebAuthn credential is a key pair: the private key lives with an authenticator (a phone, a laptop's secure hardware, a password manager, or a hardware security key), and the website stores only the public key. Discoverable means the authenticator also stores the account information and the site it belongs to, so the user can sign in without first typing a username. Older WebAuthn deployments used non-discoverable credentials, where the user had to identify themselves before the browser knew which key to ask for.

Passkeys come in two flavours. A synced passkey is copied between a user's devices by a credential provider through sync or backup, so losing a phone does not mean losing the account. A device-bound passkey cannot leave the authenticator that created it, which is typical of hardware security keys. Both are passkeys; they differ in recoverability and in how much assurance a site can attach to them.

Why the design resists phishing and breaches

Each site gets its own key pair, so there is nothing to reuse across services. The server stores only public keys, so a stolen database of them does not let an attacker sign in anywhere. The more interesting property is phishing resistance, and it comes from scoping. Every credential is bound to a relying party ID, usually the site's registrable domain. The browser, not the user, enforces that a credential created for example. com is only offered to example. com, and the signed data includes the origin the browser actually saw. A look-alike domain simply never receives a usable signature, no matter how convincing its page is.

Phishing resistance is not a property of the user being careful. It is a property of the browser refusing to hand a credential to the wrong origin.

— LeepCast

Registration: creating the key pair

Registration is called a ceremony in the specification, and it has a fixed shape. The server generates options, the browser calls navigator. credentials. create() with a publicKey object, the authenticator asks the user for consent and creates a key pair, and the browser returns a PublicKeyCredential that the server verifies and stores. The options the server sends are where most design decisions live:

  • challenge: random bytes generated by the server for this one ceremony. MDN recommends at least 16 bytes. The server must remember it and reject any response that does not echo it back.
  • rp: the relying party, with an id (the domain the credential is scoped to) and a human-readable name.
  • user: an id, name and displayName. The user id should be an opaque handle rather than an email address, because it is stored on the authenticator.
  • pubKeyCredParams: the signature algorithms the server accepts, identified by COSE numbers. The value -7 means ES256; many servers also list -257 for RS256.
  • residentKey and userVerification inside authenticatorSelection: these request a discoverable credential and say whether a biometric or PIN check is required, preferred or discouraged.
  • attestation: whether the server wants proof of which authenticator model created the key. Most consumer sites pass none.

When the response comes back, the server checks that the challenge matches the one it issued, that the origin is one it expects, and that the relying party ID hash in the authenticator data matches its own. If those pass, it stores the credential ID, the public key, the signature counter and a few flags against the user's account. Credential IDs can be reused later in excludeCredentials so the same authenticator is not registered twice.

Sign-in: signing a fresh challenge

Authentication follows the same rhythm. The server issues a new challenge, the browser calls navigator. credentials. get(), the authenticator asks the user to unlock it and signs over the authenticator data together with a hash of the client data, and the server verifies that signature with the stored public key. For discoverable credentials the server can omit allowCredentials entirely and let the browser offer whichever passkeys exist for the site.

That omission enables the experience most users now see, which the specification calls conditional mediation and passkeys. dev calls conditional UI or autofill. The page marks its username field with the webauthn autocomplete token, starts a get() call with mediation set to conditional, and the browser shows saved passkeys in the same dropdown that would normally list saved passwords:

html
<input type="text" name="username" autocomplete="username webauthn" />

Sites can check support first with PublicKeyCredential. isConditionalMediationAvailable(). When a passkey lives on a phone but the user is on a laptop, browsers can also offer cross-device authentication, where the phone performs the signing over the hybrid transport defined by CTAP and the laptop acts as the client.

Reading the authenticator data

Every create() and get() response contains authenticator data, a compact binary structure that carries most of the security-relevant facts. MDN documents its layout: 32 bytes of SHA-256 hash of the relying party ID, one byte of flags, a four-byte signature counter, and then optional attested credential data and extensions. The flags are where policy decisions are made:

  1. UP (bit 0, 0x01): the user was present, for example by touching a key or confirming a prompt.
  2. UV (bit 2, 0x04): the user was verified with a biometric, PIN or similar local check.
  3. BE (bit 3, 0x08): the credential is backup eligible, meaning it is a multi-device, syncable credential.
  4. BS (bit 4, 0x10): the credential is currently backed up.
  5. AT (bit 6, 0x40): attested credential data follows, which is present during registration.
  6. ED (bit 7, 0x80): extension data is present.

The following Node. js script builds a sample authenticator data blob and parses it the way a server-side verifier does. It runs as-is with node file. mjs on any recent Node version, needs no dependencies, and shows the checks that are easiest to get wrong: comparing the relying party hash and reading the big-endian counter.

javascript
import { createHash } from "node:crypto";

function parseAuthData(buf) {
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
  const flags = buf[32];
  return {
    rpIdHash: buf.subarray(0, 32),
    userPresent: (flags & 0x01) !== 0,
    userVerified: (flags & 0x04) !== 0,
    backupEligible: (flags & 0x08) !== 0,
    backedUp: (flags & 0x10) !== 0,
    hasAttestedCredential: (flags & 0x40) !== 0,
    signCount: view.getUint32(33, false), // big-endian
  };
}

// Build a sample 37-byte authenticator data blob for rpId "example.com"
const rpIdHash = createHash("sha256").update("example.com").digest();
const sample = Buffer.alloc(37);
rpIdHash.copy(sample, 0);
sample[32] = 0x01 | 0x04 | 0x08 | 0x10; // UP, UV, BE, BS
sample.writeUInt32BE(0, 33);            // counter not supported

const { rpIdHash: gotHash, ...fields } = parseAuthData(sample);
console.log(fields);
console.log("rpIdHash matches:", gotHash.equals(rpIdHash));

Running it prints the four flags as true, hasAttestedCredential as false, a signCount of 0 and a matching hash. That zero is deliberate. MDN notes the counter is 0 when an authenticator does not support it, so a verifier that treats a non-increasing counter as proof of a cloned key must special-case authenticators that always report zero, or it will lock out legitimate users.

The checks a server must not skip

Libraries such as SimpleWebAuthn (version 14 at the time of writing) wrap these steps in functions like verifyRegistrationResponse and verifyAuthenticationResponse, which take an expectedChallenge, expectedOrigin and expectedRPID. Using a maintained library is the right default. It still helps to know what it is doing, because the inputs are the application's responsibility:

  • Generate a new random challenge per ceremony, store it server-side, and accept it once. A reused or client-supplied challenge turns the signature into something an attacker can replay.
  • Pin the expected origin and relying party ID to exact values. Accepting any subdomain or reading the origin from a request header defeats the scoping that makes passkeys phishing resistant.
  • Decide whether UV is required. If the passkey replaces both a password and a second factor, require user verification rather than only user presence.
  • Persist the new counter returned after each successful sign-in; SimpleWebAuthn exposes it as authenticationInfo. newCounter.
  • Record the BE and BS flags. They tell support teams whether a user's passkey will survive a lost device, which is useful when deciding whether to prompt for a second passkey.

Rollout decisions that matter more than the code

Attestation is the setting teams most often over-engineer. MDN explicitly advises relying parties not to maintain allowlists of authenticators, and for most consumer products requesting no attestation keeps registration simple and private. Attestation earns its place in regulated or enterprise settings where policy requires a specific class of hardware.

Account recovery is the real design problem. A user with a single device-bound passkey who loses that key needs another way back in, and the recovery path inherits whatever weaknesses it has. Encouraging users to register a second passkey, and keeping recovery flows at least as strong as the old login, prevents the passkey from becoming a stronger front door next to an unlocked side window.

Finally, keep credential lists accurate. The WebAuthn signal methods, such as PublicKeyCredential. signalUnknownCredential() after a failed lookup and signalAllAcceptedCredentials() after a successful sign-in, let a site tell the credential provider which passkeys it no longer recognizes, so users stop being offered dead entries.

Summary

A passkey is a discoverable WebAuthn key pair whose private half never reaches the server and whose use is scoped by the browser to one relying party. Registration stores a public key after checking challenge, origin and relying party hash; sign-in verifies a fresh signature with that key. Get the challenge handling, the origin pinning, the UV policy and the counter edge case right, use a maintained verification library, and plan recovery before launch. The cryptography is the easy part.

Share

Enjoyed this?

Get the next deep dive in your inbox. No spam — just the stories worth reading.

Subscribe to the newsletter

Comments