Implementing Passkeys: A Practical WebAuthn Guide
A practical guide to implementing passkeys with WebAuthn, from registration ceremonies to server-side verification.

Passwords are the weakest link in most authentication systems, and everyone knows it. Users reuse them, phishing kits harvest them, and breach databases leak them by the billion. Passkeys, built on the WebAuthn specification, solve the problem by removing shared secrets from the equation entirely. The browser and an authenticator (a phone, a security key, or the OS keychain) hold a private key that never leaves the device — the server only ever sees a public key and a signature.
The theory is well understood by now. What trips teams up is the implementation: challenge management, origin validation, credential storage, and the UX fallback when a user's browser or device doesn't support the flow cleanly. This post walks through a production-shaped implementation, not a toy demo.
How the ceremony actually works
WebAuthn defines two "ceremonies": registration and authentication. Both are challenge-response protocols mediated by the browser's Web Authentication API.
During registration, the server generates a random challenge, the browser asks the authenticator to create a new key pair scoped to your relying party ID (rpId, typically your domain), and the authenticator returns a public key plus an attestation object. During authentication, the server issues a new challenge, the authenticator signs it with the stored private key, and the server verifies the signature against the public key it saved earlier.
Registration: server --challenge--> browser --> authenticator
authenticator --public key + attestation--> server (stored)
Authentication: server --challenge--> browser --> authenticator
authenticator --signature--> server (verified against stored key)Nothing resembling a password ever crosses the network. The challenge prevents replay attacks, and the signature proves possession of the private key without revealing it.
Registering a credential client-side
The browser API is low-level by design — it works with ArrayBuffers, not JSON — so most teams wrap it. Here's the core call, stripped of a helper library:
// client/register.js
async function registerPasskey(username) {
const optionsResponse = await fetch("/api/webauthn/register/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const options = await optionsResponse.json();
// Challenge and user.id arrive as base64url strings over JSON;
// the browser API needs raw ArrayBuffers.
options.challenge = base64urlToBuffer(options.challenge);
options.user.id = base64urlToBuffer(options.user.id);
const credential = await navigator.credentials.create({
publicKey: options,
});
await fetch("/api/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(serializeCredential(credential)),
});
}The interesting part is almost entirely server-side: generating sane registration options and verifying the response correctly.
Generating registration options
A common mistake is treating the challenge or rpId as static configuration instead of per-request state that must be validated on the way back.
// server/webauthn/options.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { randomBytes } from "node:crypto";
export async function buildRegistrationOptions(userId: string, username: string) {
const existingCredentials = await credentialRepo.findByUserId(userId);
const options = await generateRegistrationOptions({
rpName: "Acme Inc",
rpID: "acme.com", // must exactly match the origin's registrable domain
userID: Buffer.from(userId),
userName: username,
attestationType: "none", // "none" is enough for most apps; skip vendor lock-in
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "required", // discoverable credential, enables usernameless login
userVerification: "preferred",
},
});
// Store the challenge server-side (Redis, session store), never trust
// a challenge the client claims to have received.
await challengeStore.save(userId, options.challenge, { ttlSeconds: 300 });
return options;
}residentKey: "required" is what makes the credential a true passkey rather than a plain security key — it's discoverable, meaning the browser can find it without the server even sending a username first.
Verifying on the way back — where the real risk lives
This is the step people get wrong. If you skip origin or RP ID validation, you've built something that looks like WebAuthn but doesn't give you its guarantees.
// server/webauthn/verify.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
export async function verifyRegistration(userId: string, body: unknown) {
const expectedChallenge = await challengeStore.consume(userId);
if (!expectedChallenge) {
throw new Error("Challenge expired or already used");
}
// ❌ Bad: trusting whatever origin the client claims
// const verification = await verifyRegistrationResponse({
// response: body,
// expectedOrigin: body.origin,
// });
// ✅ Good: origin and RP ID are fixed server-side constants
const verification = await verifyRegistrationResponse({
response: body as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: "https://acme.com",
expectedRPID: "acme.com",
requireUserVerification: true,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed");
}
const { credentialID, credentialPublicKey, counter } =
verification.registrationInfo;
await credentialRepo.save({
userId,
credentialId: credentialID,
publicKey: credentialPublicKey,
signCount: counter,
transports: body.response.transports ?? [],
});
}The counter field matters more than it looks. Authenticators increment a signature counter on every use; if a future login presents a counter lower than or equal to what you have stored, that's a strong signal of a cloned authenticator and the login should be rejected.
Login without a username
The discoverable credential you created above lets you skip the username field entirely — the browser prompts the user to pick an account from whatever passkeys it has for your domain.
// client/login.js
async function loginWithPasskey() {
const optionsResponse = await fetch("/api/webauthn/login/options");
const options = await optionsResponse.json();
options.challenge = base64urlToBuffer(options.challenge);
const assertion = await navigator.credentials.get({
publicKey: options,
mediation: "optional", // or "conditional" for autofill-style UI
});
const result = await fetch("/api/webauthn/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(serializeAssertion(assertion)),
});
if (!result.ok) throw new Error("Login failed");
return result.json();
}mediation: "conditional" is worth calling out specifically — paired with an autocomplete="webauthn" attribute on a username field, it powers the "autofill passkey" UI that Chrome and Safari show without any extra button.
Platform support still needs a fallback path
Not every user has a compatible authenticator or browser, and corporate-managed devices sometimes disable platform authenticators outright. Passkeys should be additive, not a hard replacement, until adoption is closer to universal.
| Scenario | Recommended fallback |
|---|---|
| No platform authenticator | Cross-device (QR + Bluetooth) flow |
| Browser lacks WebAuthn support | Magic link or TOTP |
| User loses all registered devices | Identity-verified account recovery flow |
Don't build magic-link or password fallback as an afterthought bolted on after launch — design the account model from day one to support multiple credential types per user, with passkeys as the preferred, highest-trust option.
Key Takeaways
- Treat the challenge as server-side session state with a short TTL — never trust a challenge value sent back by the client.
- Pin
expectedOriginandexpectedRPIDto hardcoded values; never read them from the request body. - Use
residentKey: "required"to get true discoverable passkeys and enable usernameless, autofill-driven login. - Track and validate the signature counter on every authentication to detect cloned authenticators.
- Always ship a non-passkey fallback — account recovery and cross-browser support aren't optional in 2026.
- Store multiple credentials per user from the start; "one passkey per account" is a design trap you'll have to unwind later.
Passkeys remove an entire class of credential-theft attacks, but only if the server-side verification is as rigorous as the spec intends. The FIDO Alliance's passkey documentation is worth reading end to end before you ship this to production — the failure modes are subtle, and they're exactly the kind of thing that looks fine in a demo and breaks under an actual attacker.


