
Passkeys + WebAuthn: Kill Passwords in Production
Summary
Implement phishing-resistant passkey login in Node.js: registration, signin, recovery.
Why passkeys, why now
Passwords are the single largest attack surface most apps still ship. Phishing kits steal them, users reuse them, and breach dumps make every bcrypt hash a race against time. In 2026 the major browsers, password managers, and OS vendors finally agree on the replacement: passkeys, built on WebAuthn and FIDO2. They are phishing-resistant by construction, sync across the user's Apple/Google/Microsoft ecosystem, and require nothing more than the device biometric the user already trusts.
This guide is the production playbook. We will build a real Node.js + browser flow end to end using @simplewebauthn/server and the native navigator.credentials API: registration, sign-in, signature-counter replay protection, multi-device passkey handling, and the recovery/fallback story you absolutely cannot ship without. By the end you will know exactly what to store, what to verify, and where the foot-guns live.
If you have skimmed the WebAuthn spec before and bounced off the CBOR, the COSE keys, and the attestation statement formats, good news: a healthy server library hides almost all of that from you. The interesting work is at the boundary — issuing challenges, persisting credentials, validating signatures against the right RP ID and origin, and designing recovery. That is what we spend the rest of this guide on.
Prerequisites
- Node.js 20+ and a package manager (pnpm/npm/yarn).
- A modern browser (Chrome/Edge 119+, Safari 17+, Firefox 122+).
- A real domain (or
localhost) served over HTTPS — WebAuthn refuses plain http on non-localhost. - Basic comfort with public-key cryptography and async JavaScript.
- An existing user record (email, user_id) you can attach a credential to.
The 30-second mental model
WebAuthn has two flows: registration (the device generates a new keypair and sends the public key to your server) and authentication (the device signs a server challenge with the matching private key). The private key never leaves the secure enclave. The server's job is small but unforgiving: issue a fresh random challenge, verify the response was signed by the expected credential for the expected origin and Relying Party ID, and bump a replay counter.
- Relying Party (RP) ID — your registrable domain, e.g.
example.com. Passkeys are bound to it. - Challenge — server-issued random bytes that the client must sign. Single-use, short TTL.
- Credential ID — opaque handle the device returns. Store it indexed; you will look users up by it.
- signCount — monotonic counter the authenticator returns. Used to detect cloned credentials.
- Attestation — optional proof of authenticator make/model. Skip it for consumer apps.
The reason this is phishing-resistant is mechanical, not cultural. The browser, not your code, decides which RP ID a credential belongs to. A spoofed site at acme-login.com can ask the user for a passkey all it likes — the browser will not surface any credential issued to acme.com, full stop. There is no "are you sure?" dialog the user can fat-finger past. Compare that to passwords, which sit in a clipboard or a manager that will happily autofill into any visually similar form field, and you can see why this is the only auth scheme worth shipping in 2026.
Step 1 — Install the server library
We will use @simplewebauthn/server on the backend and @simplewebauthn/browser on the client. They handle the CBOR/COSE parsing and signature verification you should never roll yourself.
npm i @simplewebauthn/server @simplewebauthn/browser
npm i -D @types/node
Set three constants. Get them wrong and every browser will silently reject your ceremonies.
// config.ts
export const RP_NAME = "Acme Corp";
export const RP_ID = process.env.NODE_ENV === "production"
? "acme.com" // bare registrable domain, no scheme, no port, no path
: "localhost";
export const ORIGIN = process.env.NODE_ENV === "production"
? "https://acme.com" // exact scheme + host (+ port if non-default)
: "http://localhost:3000";
Step 2 — Server: registration options
When a logged-in user opts to add a passkey, your server generates registration options and stores the challenge against that user (or a short-lived session) so it can be verified on the way back.
// routes/passkey.register.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { RP_NAME, RP_ID } from "../config";
export async function getRegistrationOptions(req, res) {
const user = req.user; // your existing session user
const existing = await db.passkeys.byUser(user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: new TextEncoder().encode(user.id), // bytes, not the email
userName: user.email,
userDisplayName: user.name,
attestationType: "none", // consumer-grade: do not collect attestation
excludeCredentials: existing.map(c => ({
id: c.credentialID, // base64url string
transports: c.transports, // ["internal","hybrid","usb",...]
})),
authenticatorSelection: {
residentKey: "required", // make it a discoverable passkey
userVerification: "preferred", // biometric/PIN if available
},
});
await db.challenges.put(user.id, options.challenge, { ttl: 5 * 60 }); // 5 min
res.json(options);
}
Three details that bite people. userID must be bytes, not your email — using PII here leaks into syncing services. excludeCredentials stops the same authenticator registering twice. residentKey: "required" is what makes it a real passkey (discoverable, usable on the login page without typing a username).
Step 3 — Browser: create the credential
// app/passkey-register.ts
import { startRegistration } from "@simplewebauthn/browser";
export async function registerPasskey() {
const opts = await fetch("/api/passkey/register/options").then(r => r.json());
let attResp;
try {
attResp = await startRegistration({ optionsJSON: opts });
} catch (err) {
if (err.name === "InvalidStateError") {
throw new Error("This authenticator is already registered.");
}
if (err.name === "NotAllowedError") {
throw new Error("User cancelled or the operation timed out.");
}
throw err;
}
const r = await fetch("/api/passkey/register/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(attResp),
});
if (!r.ok) throw new Error("Server rejected the passkey");
return r.json();
}
Step 4 — Server: verify and store the credential
// routes/passkey.register-verify.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { RP_ID, ORIGIN } from "../config";
export async function verifyRegistration(req, res) {
const user = req.user;
const expectedChallenge = await db.challenges.take(user.id); // single-use!
if (!expectedChallenge) return res.status(400).send("challenge expired");
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false, // accept UV=false; gate sensitive actions on UV later
});
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).send("verification failed");
}
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
await db.passkeys.insert({
userId: user.id,
credentialID: credential.id, // base64url string
publicKey: Buffer.from(credential.publicKey), // store as bytes
counter: credential.counter,
transports: credential.transports ?? [],
deviceType: credentialDeviceType, // "singleDevice" | "multiDevice"
backedUp: credentialBackedUp, // true = synced passkey (iCloud, Google, 1Password...)
createdAt: new Date(),
});
res.json({ ok: true });
}
Take, do not read, the challenge. If your db.challenges.take is not atomic you open a replay window. Storing backedUp matters: a non-backed-up passkey on a single device is a recovery hazard — surface it in your account UI.
About credentialDeviceType: a value of multiDevice means the credential is a synced passkey (typically iCloud Keychain, Google Password Manager, Windows Hello with a Microsoft account, or a third-party manager like 1Password or Bitwarden). singleDevice means it is bound to a specific authenticator — usually a hardware security key, or a non-synced platform authenticator. Both are valid passkeys; the difference matters for how you talk to users about backup and for which device a sign-in proves possession of.
Step 5 — Server: authentication options
For sign-in we want the discoverable-credential UX: the user clicks "Sign in with passkey" with no username, the browser shows the available passkeys for your RP ID, and you find the user from the credential ID the device returns.
// routes/passkey.auth.ts
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { RP_ID } from "../config";
export async function getAuthenticationOptions(req, res) {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: "preferred",
// empty allowCredentials = discoverable passkey UX
allowCredentials: [],
});
// Bind challenge to a short-lived anonymous session cookie
await sessions.putChallenge(req.sessionId, options.challenge, { ttl: 5 * 60 });
res.json(options);
}
Step 6 — Browser: sign in (with conditional UI)
Conditional UI lets the browser autofill passkey suggestions inside the email field, the same way it autofills passwords. It is the single biggest UX win in WebAuthn — wire it up early. The user tabs into the email field, the OS popover lists their passkeys for your domain, they tap one and the form submits. No extra button, no second screen. Wire this up first; do explicit-button sign-in second as the fallback for browsers that do not support autofill discovery.
// app/passkey-login.ts
import {
startAuthentication,
browserSupportsWebAuthnAutofill,
} from "@simplewebauthn/browser";
export async function attachConditionalUI() {
if (!(await browserSupportsWebAuthnAutofill())) return;
const opts = await fetch("/api/passkey/auth/options").then(r => r.json());
// useBrowserAutofill = quietly waits for the user to pick a passkey from the email field
const assertion = await startAuthentication({ optionsJSON: opts, useBrowserAutofill: true });
await postAssertion(assertion);
}
export async function explicitSignIn() {
const opts = await fetch("/api/passkey/auth/options").then(r => r.json());
const assertion = await startAuthentication({ optionsJSON: opts });
await postAssertion(assertion);
}
async function postAssertion(assertion) {
const r = await fetch("/api/passkey/auth/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(assertion),
});
if (!r.ok) throw new Error("Sign-in failed");
location.href = "/";
}
Step 7 — Server: verify the assertion
// routes/passkey.auth-verify.ts
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { RP_ID, ORIGIN } from "../config";
export async function verifyAuthentication(req, res) {
const expectedChallenge = await sessions.takeChallenge(req.sessionId);
if (!expectedChallenge) return res.status(400).send("challenge expired");
const credentialID = req.body.id; // base64url
const stored = await db.passkeys.byCredentialId(credentialID);
if (!stored) return res.status(404).send("unknown credential");
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false,
credential: {
id: stored.credentialID,
publicKey: stored.publicKey,
counter: stored.counter,
transports: stored.transports,
},
});
if (!verification.verified) return res.status(400).send("bad signature");
// Replay protection: counter must monotonically increase, unless authenticator reports 0.
const newCounter = verification.authenticationInfo.newCounter;
if (stored.counter > 0 && newCounter <= stored.counter) {
await alerts.suspectedClone(stored.userId, credentialID);
return res.status(401).send("replay detected");
}
await db.passkeys.updateCounter(credentialID, newCounter);
await sessions.login(req, stored.userId);
res.json({ ok: true });
}
Step 8 — Recovery and fallback (do not skip this)
Every passkey rollout dies on recovery. The user buys a new phone, has no backup, and emails support at 2am. Plan three recovery lanes from day one.
- Multiple passkeys per account. Encourage users to register a second device (or a hardware key) right after the first one. UI matters: show "Add a backup passkey".
- Email magic-link as a bootstrap. Not a primary auth, just a way to authenticate the user long enough to register a new passkey. Rate-limit aggressively.
- Step-up only for the dangerous stuff. Re-prompt for UV (
userVerification: "required") before changing email, deleting the account, or rotating recovery factors.
Avoid SMS as a recovery factor in 2026 — SIM-swap attacks have only gotten cheaper. If you must, treat it strictly as a second factor on top of the magic link, never alone.
Hardware keys still matter
Synced passkeys are the right default for almost every consumer app. But for admin accounts, developer SSO, and anything that controls money or production, do not stop at "a passkey" — require at least one singleDevice credential, which in practice means a FIDO2 hardware key (YubiKey, Token2, SoloKey). The threat model is different: a synced passkey is only as private as the iCloud or Google account it lives in, and that account is itself protected by, well, a passkey. Hardware keys break the recursion — possession is provable, the private key never leaves the chip, and there is no cloud sync surface to compromise.
Operationally, mark hardware-backed credentials in your DB by stashing the aaguid from the registration response and looking it up against the FIDO Metadata Service (MDS). Any credential whose backedUp is false and whose aaguid matches a known hardware key is the strongest signal you can require for step-up auth on critical actions.
Migrating users off passwords
If you already have a logged-in user base, do not flip the switch overnight. The migration that actually works in production has three phases. Phase one: ship passkeys as an additive feature. After the user signs in with a password, prompt once to add a passkey, and remember the decline. Phase two: when a registered user has at least one synced passkey, default the sign-in page to passkey UX with a small "sign in with password" link. Watch your support volume for a few weeks. Phase three: stop offering password sign-up to new accounts entirely, then after another quarter, deprecate passwords for existing users with active passkeys. Keep the password reset flow alive only as a recovery route, gated behind email + a step-up. The whole migration usually takes a quarter or two, and the steady drop in account-takeover incidents is the metric that justifies it.
Common pitfalls and gotchas
- RP ID mismatch. If you register on
app.acme.comwithrpID: "acme.com", you must keep usingacme.com. Changing it strands every credential. - Origin checks. Behind a reverse proxy your
Originheader may differ from what the browser sends. Verify against the public URL, not the upstream one. - signCount stuck at 0. Most synced passkeys (iCloud Keychain, Google Password Manager) report counter=0 forever. Skip the monotonic check when the stored counter is 0.
- Treating discoverable and allowCredentials flows as interchangeable. They are not — empty
allowCredentialsis what triggers the picker UX. - Storing the public key as a base64 string. Store as bytes. The library returns
Uint8Array; round-trip through your DB without re-encoding. - Showing "passkey" before sniffing support. Call
browserSupportsWebAuthn()andbrowserSupportsWebAuthnAutofill(); gate the UI on the result. - Forgetting CSRF on the verify endpoints. WebAuthn does not protect you from a hostile site forcing a sign-in for the wrong account — keep your CSRF tokens.
- Letting
userVerificationdrift. Pick a policy per action and document it. "Preferred" for login, "required" for sensitive changes.
Quick reference
| Concern | Setting / API | Recommendation |
|---|---|---|
| Algorithm preference | pubKeyCredParams | ES256 (-7) and RS256 (-257), in that order |
| Discoverable passkey | residentKey | "required" |
| Attestation collection | attestationType | "none" for consumer apps |
| UV at sign-in | userVerification | "preferred" |
| UV at sensitive action | userVerification | "required" |
| Challenge TTL | server-side | ≤ 5 minutes, single-use |
| Counter check | newCounter > stored.counter | Skip when stored counter == 0 |
| Recovery | secondary factor | Magic link + multiple passkeys; avoid SMS-only |
| Token after sign-in | session cookie | HttpOnly, Secure, SameSite=Lax |
Next steps
- Wire
browserSupportsWebAuthnAutofill()+autocomplete="username webauthn"on your existing email field — most users will never need to click a button. - Add an account-settings page that lists registered passkeys with last-used timestamps and a "rename" affordance — humans cannot tell their iPhone passkey from their YubiKey otherwise.
- Instrument
credentialBackedUpin your analytics. Synced passkeys halve your support tickets. - If you serve enterprises, look at FIDO MDS for attestation-based device policy.
- Once passkeys are the default, sunset password sign-in for new accounts. The longer you keep both, the longer phishing wins.
What this looks like in your security posture
Once passkeys are the dominant sign-in method, three categories of incident drop off your dashboard almost overnight. Credential stuffing — gone, because there is nothing to stuff; the public key is useless without the private key, and rotated breach dumps cannot be replayed. Phishing kit takeovers — gone, because the browser refuses to release the credential to the wrong origin. Password reset abuse — substantially reduced, because reset is no longer the most common authentication path. What you trade for is a smaller but sharper set of new incident types: lost-device support tickets, recovery-flow abuse, and the occasional confused user who registered a passkey on a kiosk. Those are workable problems with policy and tooling. Phished credentials at scale are not.
Audit logging is also worth a second look while you are in here. Log every passkey registration, every successful sign-in (including the credential ID and backedUp flag), and every rejected verification with the reason. The verification rejection log in particular is where you will catch misconfigured deployments — a sudden spike in RP_ID mismatches usually means a frontend deployed with the wrong domain constant, not an attack.
Passwords were a 60-year stopgap. Passkeys are the first credential designed for the threat model we actually live in. Ship them well, plan recovery before launch, instrument the verification path, and your future on-call self will thank you.
Comments
Be the first to comment