Implementing Passkeys and WebAuthn
Passwords are the single largest credential-theft surface in production authentication, and passkeys retire that surface by replacing shared secrets with origin-bound public-key cryptography. This walkthrough is part of the Modern Authentication Fundamentals guide, and it covers the full WebAuthn model end to end: how the relying party, the authenticator, and the browser cooperate across a registration ceremony and an authentication ceremony, and how to ship both with @simplewebauthn/server and @simplewebauthn/browser without leaving exploitable gaps.
WebAuthn is a W3C Recommendation (Web Authentication: An API for accessing Public Key Credentials, Level 2/3), and it sits on top of the FIDO Alliance’s CTAP2 protocol that lets browsers talk to authenticators over USB, NFC, BLE, or the platform’s internal secure element. Together they are marketed as FIDO2. A “passkey” is simply a discoverable WebAuthn credential — a public-key credential the authenticator can find and present without the server first naming it — often synced across a user’s devices through iCloud Keychain, Google Password Manager, or a third-party manager.
The WebAuthn model
Three roles participate in every ceremony:
- Relying party (RP): your server and its origin. The RP is identified by an RP ID — a registrable domain suffix such as
example.com— and credentials are cryptographically scoped to it. This scoping is what makes WebAuthn phishing-resistant: a credential minted forexample.comwill not be offered onexamp1e.com, because the browser refuses to match the origin. - Authenticator: the hardware or software that generates and stores the key pair. Platform authenticators are bound to one device (Touch ID, Windows Hello, an Android device’s secure element). Roaming authenticators are portable (a YubiKey or any CTAP2 security key) and can move between machines.
- Public-key credential: an asymmetric key pair. The private key never leaves the authenticator; only the public key is sent to and stored by the RP. Each credential carries a unique credential ID the RP uses to address it later.
Two ceremonies use these roles. Registration (also called attestation, or “make credential”) creates a new key pair and hands the public key to the server. Authentication (also called assertion, or “get assertion”) proves possession of an existing private key by signing a server challenge. Both are anchored by a server-generated, single-use challenge and by origin binding performed in the browser.
Prerequisites
Before writing any code, confirm the following — most production WebAuthn bugs trace back to one of these being wrong:
- HTTPS everywhere except
localhost. WebAuthn requires a secure context.localhostis exempt for development. - A stable RP ID. Decide whether your RP ID is
example.com(covers all subdomains, where the browser allows it) orapp.example.com(narrower). The RP ID must be the registrable suffix of the page origin. Changing it later invalidates every existing credential. - An exact expected origin list, e.g.
https://app.example.com. The browser reports the full origin during the ceremony and the server must compare it byte-for-byte. - Per-user, server-side challenge storage (a session or short-TTL store). The challenge is one-time and must be read back during verification.
- A credentials table keyed by credential ID, storing the public key, the signature counter, the transports, and the owning user. Plan this schema before you start.
Install the libraries:
npm install @simplewebauthn/server @simplewebauthn/browser
Server + client implementation
The four endpoints below form a complete passkey system. The deep mechanics of the first pair live in the passkey registration ceremony walkthrough; the second pair are dissected in verifying passkey authentication assertions.
1. Shared configuration
Centralize the RP identity so every endpoint agrees on it. Drift between these constants is the most common cause of RP ID mismatch and origin mismatch failures.
// webauthn-config.ts
export const rpName = "Example App";
export const rpID = process.env.WEBAUTHN_RP_ID ?? "app.example.com";
export const origin = process.env.WEBAUTHN_ORIGIN ?? `https://${rpID}`;
2. Registration options (server)
// routes/register-options.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { rpName, rpID } from "../webauthn-config";
export async function registerOptions(req, res) {
const user = req.user; // authenticated or pending account
const existing = await db.getCredentialsForUser(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userID: new TextEncoder().encode(user.id),
attestationType: "none",
// Block re-registering an authenticator the user already enrolled.
excludeCredentials: existing.map((c) => ({
id: c.credentialID,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required", // make it a discoverable passkey
userVerification: "preferred",
},
supportedAlgorithmIDs: [-7, -257], // ES256, RS256 — explicit allowlist
});
// Single-use challenge, bound to this user, server-side.
await sessionStore.set(req.sessionId, { currentChallenge: options.challenge });
res.json(options);
}
The explicit supportedAlgorithmIDs allowlist (COSE -7 = ES256, -257 = RS256) is deliberate: it refuses any algorithm you have not vetted. There is no WebAuthn equivalent of a JWT alg: none because the structure is fixed, but pinning algorithms keeps weak or experimental curves out of your credential store.
3. Registration verify (server)
// routes/register-verify.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { rpID, origin } from "../webauthn-config";
export async function registerVerify(req, res) {
const { currentChallenge } = await sessionStore.get(req.sessionId);
if (!currentChallenge) return res.status(400).json({ error: "no challenge" });
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: "verification failed" });
}
const { credential } = verification.registrationInfo;
await db.saveCredential({
userId: req.user.id,
credentialID: credential.id,
publicKey: credential.publicKey, // store as bytea/blob
counter: credential.counter, // signature counter starts here
transports: req.body.response.transports ?? [],
});
await sessionStore.delete(req.sessionId); // burn the challenge
res.json({ verified: true });
}
4. Authentication ceremony (server)
// routes/login-options.ts
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { rpID } from "../webauthn-config";
export async function loginOptions(req, res) {
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
// allowCredentials omitted -> usernameless / discoverable login.
});
await sessionStore.set(req.sessionId, { currentChallenge: options.challenge });
res.json(options);
}
// routes/login-verify.ts
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { rpID, origin } from "../webauthn-config";
export async function loginVerify(req, res) {
const { currentChallenge } = await sessionStore.get(req.sessionId);
const credential = await db.getCredentialById(req.body.id);
if (!credential || !currentChallenge) return res.status(400).end();
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: currentChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialID,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
requireUserVerification: false,
});
if (!verification.verified) return res.status(401).end();
// Clone-detection: persist the advanced counter.
await db.updateCounter(credential.credentialID, verification.authenticationInfo.newCounter);
await sessionStore.delete(req.sessionId);
// Bind the verified passkey to a fresh server-side session.
await issueSession(res, credential.userId);
res.json({ verified: true });
}
5. Browser glue
@simplewebauthn/browser handles base64url encoding and the navigator.credentials calls so you never touch raw ArrayBuffers:
// client.ts
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
export async function enroll() {
const optionsJSON = await fetch("/register/options", { method: "POST" }).then((r) => r.json());
const attResp = await startRegistration({ optionsJSON });
await fetch("/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attResp),
});
}
export async function signIn() {
const optionsJSON = await fetch("/login/options", { method: "POST" }).then((r) => r.json());
const asseResp = await startAuthentication({ optionsJSON });
await fetch("/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(asseResp),
});
}
Validation & testing
- Round-trip in Chrome DevTools. Open the WebAuthn tab (More tools → WebAuthn), enable a virtual authenticator, and run both ceremonies. The virtual authenticator lets you simulate platform and roaming devices and increment counters without hardware.
- Confirm the challenge is consumed. Replay the same
/register/verifybody twice; the second call must fail because the challenge was deleted. - Assert origin rejection. Point a second origin at the API and confirm
verifyAuthenticationResponsethrows on the mismatch. - Inspect stored material. The
publicKeyshould be raw COSE bytes, thecounteran integer, andcredentialIDbase64url — log nothing else.
Common misconfigurations
| Symptom | Root cause | Fix |
|---|---|---|
The RP ID is not a registrable domain suffix |
RP ID doesn’t match the page origin’s domain | Set rpID to the registrable suffix of the serving origin; never an arbitrary string |
| Verification always fails with origin error | expectedOrigin includes a port/scheme that differs from the browser’s reported origin |
Compare the exact https://host[:port] the browser sends; keep one source of truth |
Challenge mismatch on every attempt |
Challenge not stored, or stored globally instead of per-session | Persist the challenge server-side keyed to the user’s session and read it back on verify |
| Stolen credential keeps working forever | Signature counter never persisted or never compared | Store newCounter after each assertion and reject non-increasing counters |
| Users can’t log in across devices | Credential created non-discoverable (residentKey: "discouraged") |
Set residentKey: "required" to mint a true passkey |
Security implications
WebAuthn’s core property is phishing resistance through origin binding. Because the browser injects the real origin into the signed client data and the authenticator scopes keys to the RP ID, a credential cannot be exercised on a look-alike domain — the attack that defeats passwords, TOTP codes, and SMS OTPs simply does not apply. The private key is non-exportable from the authenticator, so server breaches leak only public keys, which are useless to an attacker.
The signature counter provides clone detection: a hardware authenticator increments a monotonic counter on each assertion, so a non-increasing value signals a duplicated credential and should fail closed. (Synced passkeys frequently report a counter of zero across devices, so treat zero as “counter not supported” rather than an error — covered in the authentication assertions page.) Because a successful assertion only proves possession, you must still bind it to a regenerated, HttpOnly, Secure cookie session and rotate the session ID on login to close session fixation windows.
Passkeys do not eliminate the need for a recovery path or for layered factors. Pair them with the multi-factor authentication TOTP and FIDO2 guide for accounts that still keep a password, and plan passkey account recovery and fallback strategies before launch so a lost device does not become a lost account.
Why Passkeys Resist Phishing When Nothing Else Does
Every shared-secret factor — passwords, one-time codes, push approvals — can be relayed. The user is on a convincing replica of your login page, they type the code, and the attacker’s proxy forwards it to the real site within the validity window. WebAuthn breaks that relay at the protocol level, and it is worth being precise about how, because the mechanism explains most of the implementation rules.
Three implementation rules follow directly. Verify origin against an exact allowlist rather than a suffix match, because yourapp.com.evil.example ends with your domain as a string. Verify that the relying-party ID hash in the authenticator data matches the RP ID you configured, since that is the value the credential is scoped to. And keep the challenge single-use and server-side: a challenge stored in the client, or accepted twice, turns a signature into a replayable token.
Counter handling deserves a note because it produces support tickets. The signature counter is intended to detect a cloned authenticator, but many platform authenticators — including the ones backing synced passkeys — always report zero. The correct rule is: if the stored counter and the new counter are both zero, accept; if the new counter is greater than the stored one, accept and update; if it went backwards on an authenticator that reports non-zero counters, treat it as a possible clone and require another factor. Rejecting all zero counters locks out most consumer devices.
Rolling Passkeys Out Without Locking Anyone Out
The technical ceremony is the easy part; the deployment sequence is where products get into trouble. A workable order looks like this.
Start by offering passkeys as an additional factor alongside the existing password, and make enrolment opt-in from account settings. This produces real telemetry — how many users have compatible devices, how many complete enrolment, how many come back and use it — without risking anyone’s access. Prompt for enrolment after a successful login rather than before, when the user has already proved who they are and has no reason to abandon.
Encourage a second credential immediately after the first. A single passkey on a single device is a lockout waiting for a lost phone, and the moment right after a successful enrolment is when the user understands what they are being asked to add. Show which devices hold credentials, with a name and a last-used date, so removing an old laptop is obvious.
Only after adoption is meaningful should you offer passwordless as the default, and only for accounts with at least two credentials or a verified recovery path. Keep the password as a fallback longer than feels necessary; removing it is a one-way door for users whose devices break, and the recovery and fallback strategies you provide determine how many support tickets that door generates.
Throughout, instrument the ceremony failures. Browsers report errors — NotAllowedError for a cancelled or timed-out prompt, InvalidStateError when the credential already exists on the device — and the distribution of those errors tells you whether your UX or your configuration is wrong. A spike in NotAllowedError usually means the prompt is appearing without explanation; a spike in origin or RP ID errors means a configuration mismatch between environments.
Frequently Asked Questions
What is the difference between a passkey and a WebAuthn credential?
Technically none — a passkey is a WebAuthn credential. The word marks a set of properties the platforms standardised on: the credential is discoverable (the authenticator can find it without the server naming it first, which is what makes usernameless login possible), it is backed by user verification such as a biometric or PIN, and on most platforms it syncs through the vendor’s keychain so it survives losing a device. Older security-key credentials are the same protocol without those properties.
Do synced passkeys weaken security?
They move part of the trust to the platform vendor’s keychain, which is a real change but usually a favourable one. The alternative — a credential that exists on exactly one device — fails closed in a way users experience as being locked out, and the recovery flow you build to compensate is almost always weaker than the vendor’s account security. If your threat model genuinely requires it, request device-bound credentials with attestation and accept the support burden deliberately.
Can I use the same relying-party ID across subdomains?
Yes, if you set the RP ID to the registrable parent domain — a credential scoped to example.com works on app.example.com and admin.example.com. That is convenient and it widens the scope: any subdomain that can serve a login page can request an assertion for that credential. Scope the RP ID as narrowly as your deployment allows, and never set it to a domain you do not fully control.
What happens if a user loses every device with a passkey?
That is exactly why the recovery path has to be designed before you go passwordless. The usual layers are a second passkey on another device, a verified email or phone challenge, recovery codes generated at enrolment, and — for high-value accounts — an identity-proofing step with a human. Whatever you choose becomes the real strength of the account, because an attacker will attack the weakest path, not the strongest one.
Should I require attestation?
For consumer products, no: requesting attestation adds a privacy-sensitive prompt on some platforms, complicates the flow, and tells you little you will act on. Enterprise deployments that must restrict authentication to approved hardware are the genuine use case — there, you verify the attestation statement against the FIDO metadata service and enforce an allowlist of authenticator models, and you accept that users’ personal devices will be refused.
Credential Inventory Users Can Act On
Related
- Building the passkey registration ceremony — the server-side
generateRegistrationOptions/verifyRegistrationResponseflow and credential persistence. - Verifying passkey authentication assertions — assertion verification, the signature-counter clone check, and session binding.
- Passkey account recovery and fallback strategies — recovering accounts without reintroducing phishing-prone factors.
- Multi-factor authentication with TOTP and FIDO2 — combining passkeys with other factors and step-up flows.
- Configuring secure cookie flags in production — hardening the session cookie you issue after a successful assertion.