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.
sequenceDiagram
participant U as User + Authenticator
participant B as Browser
participant RP as Relying Party Server
Note over RP: Registration ceremony
B->>RP: POST /register/options
RP->>RP: generateRegistrationOptions\nstore challenge in session
RP-->>B: options + challenge
B->>U: navigator.credentials.create
U->>U: create key pair\nuser verification
U-->>B: attestation response
B->>RP: POST /register/verify
RP->>RP: verifyRegistrationResponse\npersist credentialID, publicKey, counter
RP-->>B: registered
Note over RP: Authentication ceremony
B->>RP: POST /login/options
RP->>RP: generateAuthenticationOptions\nstore challenge
RP-->>B: options + challenge
B->>U: navigator.credentials.get
U->>U: sign challenge with private key
U-->>B: assertion + signature
B->>RP: POST /login/verify
RP->>RP: verifyAuthenticationResponse\ncheck signature + counter
RP-->>B: session cookie
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.
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.