Passkeys: a seal instead of a password
A password is a secret you show to anyone who asks.
You type it into a site — the site sees it. The site stores a hash — the hash leaks along with the database. You came up with one good password and use it in five places — one leak opens all five. And a phishing page someone threw together in an hour gets it simply because you typed it in yourself.
Two-factor authentication fixes half of this. SMS codes get intercepted with a SIM swap. An authenticator-app code gets requested by the phishing page right after the password and forwarded to the real site within the same thirty seconds. Both factors are strings a person types by hand. And where they type them, they don’t check.
Passkeys work differently. The secret never leaves your device. The site stores only things that are useless to steal. And the browser won’t hand a signature to someone else’s domain, no matter how nicely it’s asked.
What follows is how this works under the hood, what it looks like for the person using it, and how to add passkeys to your own site in JavaScript. The code in this article is a working minimum: copy it, run it on localhost, and in half an hour you’ll have passwordless sign-in.
Seven words for one mechanism
Words first. What trips up a newcomer here isn’t the cryptography — it’s that the same thing goes by several different names.
| Word | What it is |
|---|---|
| WebAuthn | The browser API: navigator.credentials.create() and navigator.credentials.get(). A W3C standard. |
| CTAP | The protocol the browser uses to talk to an authenticator — over USB, NFC, Bluetooth. |
| FIDO2 | WebAuthn and CTAP together. Named after the alliance that came up with it. |
| Passkey | The marketing name for a WebAuthn key that can be found without a username and usually syncs across devices. |
| Authenticator | Whatever holds the key and does the signing: Touch ID, Windows Hello, a phone, a password manager, a hardware key. |
| Relying party (RP) | Your site. The one that checks the signature. |
| RP ID | The domain the key is bound to. Usually example.com. |
In short: developer docs say “WebAuthn”, your phone’s UI says “passkey”. Same thing.
A seal and its impression
Imagine a seal that can’t be forged from its impression.
When you sign up, you leave a sample impression with the site and take the seal home. The site files the sample in its database. When you come back, the site hands you a sheet of paper with a random word on it — a new one every time. You stamp it. The site compares the stamp with the sample: if they match, it’s you.
All anyone can steal from the database is samples. You can’t stamp anything with a sample.
An old stamped sheet won’t work either: it has yesterday’s word on it, and today the site wrote a different one.
In real life, of course, you can forge a seal from its impression. In math you can’t — that’s what the whole thing rests on. The seal is the private key. The impression is the public key. The sheet with the word is the challenge. Stamping it is signing.
A password in this picture would look like this: you hand the site the seal itself, it looks at it and gives it back. Every time. To every site.
Registration: the site gets the impression
The whole exchange fits into two requests to the server with one browser API call in between.
Here’s what the server sends the browser in response to the first request:
{
"challenge": "d3zie-L8EA4SdufVnMxT5fD_lwhKnhdV4HCcZko-XMc",
"rp": { "name": "My site", "id": "localhost" },
"user": {
"id": "hGJlYNa3zAioNdE7KCtsbhzLBXtYa8cqDktdEU78wsk",
"name": "alex@example.com",
"displayName": ""
},
"pubKeyCredParams": [
{ "alg": -8, "type": "public-key" },
{ "alg": -7, "type": "public-key" },
{ "alg": -257, "type": "public-key" }
],
"timeout": 60000,
"attestation": "none",
"excludeCredentials": [],
"authenticatorSelection": {
"residentKey": "required",
"userVerification": "preferred",
"requireResidentKey": true
},
"extensions": { "credProps": true },
"hints": []
}
The library fills in most of this. Three fields are worth understanding.
challenge is the word on the paper. Thirty-two random bytes the server keeps in the session. The authenticator will sign a response that contains this challenge, and the server will compare it with its own copy.
user.id is not an email. It’s a random identifier the authenticator stores next to the key and returns at sign-in. Don’t put an email in it: the authenticator makes no promise to keep it secret.
pubKeyCredParams lists the algorithms the server can verify. -8 is Ed25519, -7 is ECDSA on P-256, -257 is RSA. The numbers come from the COSE registry. The authenticator picks the first one it supports.
Next, the browser asks the person, and the authenticator creates a key pair and keeps the private half. A response goes to the server. Its most readable part is clientDataJSON — plain JSON, encoded as base64url:
{"type":"webauthn.create","challenge":"d3zie-L8EA4SdufVnMxT5fD_lwhKnhdV4HCcZko-XMc","origin":"http://localhost:3000","crossOrigin":false}
The origin field was written by the browser, not the page, and the page can’t change it. Remember it — a little later it’s what saves you from phishing.
There’s also attestationObject — a binary blob holding the public key, its identifier and a hash of the domain. You don’t need to take it apart by hand. The library checks it.
Sign-in: the site checks the signature
The sign-in options are shorter — there’s no username in them at all:
{"rpId":"localhost","challenge":"EkEX66UE_seQWqTJTlzcZ_pPueUGMJyUqJcBp2Nm9jc","timeout":60000,"userVerification":"preferred"}
The server doesn’t ask who you are. The authenticator already knows which keys it holds for localhost, shows the list, the person picks one — and the key’s id comes back in the response. The server uses it to find the public key in the database and check the signature.
It’s not just the challenge that gets signed. The authenticator glues two things together: its own data (a hash of the domain, the flags “a person was present” and “the person verified themselves”, a counter) and a hash of clientDataJSON, which holds the challenge and the origin. Change a single byte of that and the signature won’t match.
And this is what clientDataJSON looks like at sign-in:
{"type":"webauthn.get","challenge":"EkEX66UE_seQWqTJTlzcZ_pPueUGMJyUqJcBp2Nm9jc","origin":"http://localhost:3000","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}
That last field isn’t a typo. Chrome sometimes adds it on purpose: if your server compares the JSON against a template string, better it breaks on your laptop than for your users. So you parse this JSON, you don’t compare it.
A phishing page has nothing to sign
Now for the reason passkeys are worth adding at all.
Imagine an attacker has copied your site exactly: same markup, same script, they even proxy requests to your real server. The only difference is the address. In our example the real site lives on localhost and the double on myslte.test. The person doesn’t notice and clicks “Sign in”. The browser answers:
SecurityError: The RP ID "localhost" is invalid for this domain
The browser refused before the authenticator was even involved. The page on myslte.test asked for a signature for localhost, and a browser only lets a page ask for its own domain or a parent of it.
The attacker changes tactics and asks for a signature for their own domain:
NotAllowedError: The operation either timed out or was not allowed.
The authenticator has nothing to offer. The key was created for localhost; for myslte.test there’s nothing. The person has nothing to pick, so they give nothing away.
Phishing fails either way. And not because the user got more careful.
Because nobody asked them.
A password or a code can be typed anywhere. A passkey exists for exactly one domain, and the browser writes the page’s address into the signature. The “is this the right site?” check is done by a machine — it doesn’t get tired and it’s never in a hurry.
Where the seal lives
Where the key is kept is decided by the user, not the site. And almost everything they’ll see depends on it.
| Synced passkey | Device-bound | |
|---|---|---|
| Where | iCloud Keychain, Google Password Manager, 1Password, Bitwarden | YubiKey and other hardware keys, sometimes a laptop’s TPM |
| New device | The key is already there | Has to be registered again |
| Lost device | Nothing is lost | The key is gone with it |
| Who can copy it | Whoever gets into the cloud account | Nobody — the key can’t be extracted |
credentialDeviceType |
multiDevice |
singleDevice |
The left column promises “I won’t lose it”, the right one “nobody will copy it”. I use both myself: everyday keys live in 1Password, and for what matters most there’s a hardware key. Your site should accept either just as happily.
There’s also a third option few people know about. Sit down at someone else’s computer and the browser shows a QR code. You scan it with your phone, the phone checks over Bluetooth that it’s nearby, and signs. The key never leaves the phone. This is called hybrid transport. Bluetooth is there purely for the “nearby” check: without it, a QR code from a phishing page could be forwarded to a victim in a chat app.
What the user sees
For the user, all this machinery is nearly invisible. That’s the idea.
Create one. An “Add a passkey” button in account settings. A system prompt, Touch ID or the phone’s PIN — done. Nothing to make up.
Sign in. You click the username field and the browser offers your account, marked “passkey”. You pick it and touch the sensor. No password, no SMS code.
Lose your phone. If the key was in iCloud, Google or a password manager, it’s already waiting on the new phone. If it was a hardware key, you sign in another way and set up a new one. That’s why an account should have more than one key.
Only that last point makes a person think about anything. How to help them with it is below, in the part about account recovery.
Wiring it up: four endpoints and two calls
You don’t need to write signature verification yourself. For JavaScript there’s SimpleWebAuthn, and nearly everyone uses it: @simplewebauthn/server on the server and @simplewebauthn/browser in the browser. The examples below use versions 14.0.2 and 14.0.0, Express 5 and express-session.
npm i @simplewebauthn/server @simplewebauthn/browser express express-session
The server needs to know three things about itself:
const rpName = 'My site';
const rpID = 'localhost';
const origin = 'http://localhost:3000';
// Two arrays standing in for a database.
const users = [];
const passkeys = [];
In production these become example.com and https://example.com. WebAuthn only works over HTTPS. The one exception is localhost, so you don’t need a certificate for development.
Registration on the server
You’ll need four endpoints in total: two for registration, two for sign-in. The first one prepares the options and remembers the challenge:
app.post('/passkeys/register/options', async (req, res) => {
let user = users.find((u) => u.email === req.body.email);
if (!user) {
user = { id: users.length + 1, email: req.body.email };
users.push(user);
}
const existing = passkeys.filter((p) => p.userId === user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
});
req.session.challenge = options.challenge;
req.session.pendingUserId = user.id;
res.json(options);
});
residentKey: 'required' is what makes the key a real passkey. The authenticator stores it together with user.id and can find it on its own, without a hint from the server. Without this line, the key only works if the server already knows who’s signing in. In other words, the person would still have to type a username.
excludeCredentials lists the keys the person already has. If the authenticator recognizes one of its own among them, it won’t create a second. Without it, someone who clicks “Add a passkey” three times ends up with three identical keys and no idea which one to delete.
The second endpoint verifies the response and saves the key:
app.post('/passkeys/register/verify', async (req, res) => {
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch (error) {
return res.status(400).json({ error: error.message });
} finally {
delete req.session.challenge;
}
if (!verification.verified) return res.status(400).json({ error: 'not verified' });
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
passkeys.push({
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
userId: req.session.pendingUserId,
});
res.json({ verified: true });
});
Note the finally: the challenge is removed from the session on success and on failure alike. One word, one sheet of paper. It can’t be used twice.
After registration, the “database” holds something like this — the byte array is shortened:
{
id: 's78-I0rO1c8bKYqJ-x6n_DGbtGuD_ZXUM8JoxTcugDU',
publicKey: Uint8Array(42) [ 164, 1, 1, 3, 39, 32, 6, 33, 88, 32, 112, 253, ... ],
counter: 1,
transports: [ 'internal' ],
deviceType: 'singleDevice',
backedUp: false,
userId: 1
}
Forty-two bytes of public key. That’s all your site stores about how a user signs in — and you could publish those bytes on a billboard.
In a real database this is a single table:
| Column | Why |
|---|---|
id |
The key’s identifier; this is what you look up at sign-in. Unique index. |
public_key |
A BLOB; it verifies the signature. |
counter |
Protection against cloned keys — see below. |
transports |
A hint to the browser on where to look for the key: internal, usb, hybrid. |
device_type, backed_up |
Whether the key syncs. Useful for deciding whether to ask the person to add a second one. |
user_id |
Whose it is. One user has many. |
created_at, last_used_at |
So the person can tell their keys apart in settings. |
Registration in the browser
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthnAutofill,
} from '@simplewebauthn/browser';
const result = document.querySelector('#result');
async function post(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
return response.json();
}
document.querySelector('#register').addEventListener('click', async () => {
const email = document.querySelector('[name=email]').value;
const optionsJSON = await post('/passkeys/register/options', { email });
let attestation;
try {
attestation = await startRegistration({ optionsJSON });
} catch (error) {
result.textContent = `${error.name}: ${error.message}`;
return;
}
const verdict = await post('/passkeys/register/verify', attestation);
result.textContent = verdict.verified ? 'Passkey saved' : verdict.error;
});
Why a library here at all? The browser API takes and returns binary buffers, while what travels over the network is JSON, which can’t hold them. startRegistration converts one into the other, calls navigator.credentials.create() and converts the response back. By hand that’s fifty boring lines.
Sign-in on the server
app.post('/passkeys/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
});
req.session.challenge = options.challenge;
res.json(options);
});
app.post('/passkeys/login/verify', async (req, res) => {
const passkey = passkeys.find((p) => p.id === req.body.id);
if (!passkey) return res.status(400).json({ error: 'unknown passkey' });
let verification;
try {
verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: passkey.id,
publicKey: passkey.publicKey,
counter: passkey.counter,
transports: passkey.transports,
},
requireUserVerification: false,
});
} catch (error) {
return res.status(400).json({ error: error.message });
} finally {
delete req.session.challenge;
}
if (!verification.verified) return res.status(400).json({ error: 'not verified' });
passkey.counter = verification.authenticationInfo.newCounter;
req.session.userId = passkey.userId;
const user = users.find((u) => u.id === passkey.userId);
res.json({ verified: true, email: user.email });
});
We don’t send a list of allowed keys (allowCredentials) in the options, so any key for this site will do. The server learns who arrived only from the response, by the key’s id.
Sign-in in the browser — and why it starts on its own
async function signIn({ autofill }) {
const optionsJSON = await post('/passkeys/login/options');
let assertion;
try {
assertion = await startAuthentication({ optionsJSON, useBrowserAutofill: autofill });
} catch (error) {
// The person never started the background autofill request, so they never hear about its errors.
if (!autofill) result.textContent = `${error.name}: ${error.message}`;
return;
}
const verdict = await post('/passkeys/login/verify', assertion);
result.textContent = verdict.verified ? `Signed in as ${verdict.email}` : verdict.error;
}
document.querySelector('#signin').addEventListener('click', () => signIn({ autofill: false }));
if (await browserSupportsWebAuthnAutofill()) signIn({ autofill: true });
There are two ways in here, and the second matters more than the first.
The “Sign in with a passkey” button opens a system prompt. That works, but the person has to remember they have a passkey and find that button.
Autofill is the one people won’t miss. A request with useBrowserAutofill: true starts when the page loads and quietly waits. When the person puts the cursor in the username field, the browser shows their passkey in the dropdown, next to saved passwords. Pick it — you’re in. All it needs is the right autocomplete on the field:
<input name="email" type="email" autocomplete="username webauthn" placeholder="email">
The word webauthn at the end is what turns the suggestion on. The spec calls this conditional mediation; articles usually call it conditional UI.
Errors from the background request are never shown to the person. That’s what the condition in catch is for. The library cancels the autofill request whenever another one starts — registration from the button, for example. The person never started that request, and a message about it being cancelled would only scare them. Show errors only for what the person started themselves.
The password goes away — or stays as step one
Everything above is sign-in where the passkey replaces both the password and the second factor. That’s the best option, but not the only one.
Passkey as the only way in. The person signs up with an email and creates a key right away. They sign in with the key and nothing else. There’s no password in the system at all, and nothing in the database worth stealing. A good fit for new projects.
Passkey as the second step. You already have passwords, users are used to them, and dropping them overnight is scary. Then the key replaces the SMS code: the person enters the password, the server checks it and asks for a key — this specific user’s key.
app.post('/login/second-factor/options', async (req, res) => {
const userId = req.session.halfSignedInUserId;
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: passkeys
.filter((p) => p.userId === userId)
.map((p) => ({ id: p.id, transports: p.transports })),
userVerification: 'discouraged',
});
req.session.challenge = options.challenge;
res.json(options);
});
There are two differences. allowCredentials lists this person’s keys — the server already knows who’s signing in. And userVerification: 'discouraged' says no fingerprint or PIN is needed, a touch is enough: the password already confirmed who they are, and all the key has to prove is “the device is with me”.
Verifying the response is the same as for a normal sign-in. Add just one condition: the key you found must belong to this particular user.
To be honest about the cost: the password stays in the database and can still leak. But phishing stops working — even with the password, an attacker hits a second step they can’t relay. For a product that’s already live this is usually the right place to start, and you can drop the password later.
userVerification: a touch or a fingerprint
This setting confuses almost everyone, because it’s about two similar things.
User presence — “someone pressed the key”. A tap on a YubiKey, a “Continue” button.
User verification — “it’s actually the owner”: a fingerprint, a face, the device PIN.
There are three values. 'required' — the key won’t work without verifying the person. 'preferred' — verify if you can. 'discouraged' — don’t bother the person.
Passwordless sign-in needs verification: otherwise the key is just an object someone can steal along with the laptop. But Touch ID, Windows Hello and phones always do it anyway. So for most sites 'preferred' in the options and requireUserVerification: false on the server are enough. If you need a guarantee, use 'required' and true.
Almost nobody needs attestation
The registration options had "attestation": "none". That means the server doesn’t ask which device created the key.
You can ask. Then the authenticator attaches the manufacturer’s certificate, and the server learns the device model and can check that it really is, say, a YubiKey. The model is identified by its AAGUID, and certificates are checked against the FIDO Metadata Service registry.
You need this where the rules are about hardware. A company handed its employees YubiKeys and doesn’t want anyone signing in with a key from iCloud. A regulator requires a bank to use certified devices. For everyone else, attestation brings just two things: rejected users whose password manager is the “wrong” one, and a list of manufacturers you’ll have to maintain. Leave it at none.
Where it breaks
Change the domain, lose every key. A key is bound to the RP ID. Move from example.com to example.io and not a single key will work. A database migration won’t help: signing happens on the user’s device. So you pick the RP ID once, and pick it broadly. A key for example.com works on app.example.com and login.example.com too. Not the other way around.
Account recovery by email. The person lost every device, and every key with them. What now? The simplest answer is a link sent by email: it lets them in so they can create a new key. But remember that the account is now exactly as secure as their inbox. You can reduce the risk: after a sign-in via link, allow nothing but creating a new key, and immediately send a notice about it to that same address. You can’t remove the risk entirely.
One key means zero spares. Show people a list of their keys with dates and names, and suggest adding a second one. Especially if a key has backedUp: false: that key disappears along with its device.
Synced keys always report a counter of zero. counter is meant to catch clones: the authenticator increments it on every sign-in, and if the server sees a number lower than the stored one, the key has been copied. A hardware key counts exactly like that: 1, 2, 3. But an iCloud key lives on several devices at once, they share no counter, and they send 0. The library knows this and doesn’t complain. Just don’t rely on this protection: for these keys, the cloud account plays that role.
One challenge per session. In the example above there’s a single slot for the challenge in the session, and two parties can be waiting on it at once: autofill and the button. Whoever asked last wins, and the other gets a 400. As long as the background request’s errors aren’t shown to the person, that’s harmless.
No HTTPS, no passkeys. Outside localhost, without HTTPS the browser simply won’t give you navigator.credentials.
You can test without a finger. Chrome DevTools has a WebAuthn panel: turn on a virtual authenticator there and the browser signs without Touch ID. In automated tests you do the same thing through Playwright:
const cdp = await context.newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
That’s how you cover both registration and sign-in in CI. One difference from a live browser: the virtual authenticator answers the autofill request right away, without waiting for a click on the field. A live browser waits until the person picks an account.
So why are passwords still here
A fair question: if all this is so good, why does every other site still have a password form?
Because of the first-time setup. Nobody needs a password explained: everyone knows a field full of dots. With a passkey, a person has to understand what a “key” is, where it’s kept and what happens if they lose their phone. And a developer has to wade through seven names for one mechanism, two procedures and settings with names that sound alike.
That’s exactly the part we just walked through. And once everything is set up, the password doesn’t win a single scenario. A passkey is faster. It can’t be made weak, reused on two sites, read out to a scammer over the phone, or typed into a fake page.
Where this is heading
The big platforms have already made their choice. New Microsoft accounts are passwordless by default. Google and Apple offer a passkey before a password. Password managers have agreed on a format for moving keys between each other — until recently, a passkey created in iCloud stayed there forever.
And the standard itself is smoothing over the awkward parts. A site can now tell the password manager that a key was deleted on the server, so it stops offering it — in SimpleWebAuthn that’s sendSignal(). One key can work across several domains owned by the same company. And the browser can offer to create a passkey right after a password sign-in, with no trip to the settings page.
It’s all heading toward users not having to understand how any of this works. We’ll do the understanding for them.
Passwords will stick around for a while — as a fallback and out of habit. But they won’t be the main way in again.
You keep the seal. The site only gets the impression.
Comments 0
No comments yet.