Add passkeys with WebAuthn
Implement passkey registration and login using the WebAuthn API and a server-side verification library, the phishing-resistant replacement for passwords.
- A backend you can add two endpoints to
- HTTPS (or localhost) and a defined relying party ID
- A WebAuthn server library such as @simplewebauthn/server
Passkeys are WebAuthn credentials: a public/private key pair where the private key never leaves the user's device or password manager. Authentication is a signed challenge, so there is no shared secret to phish or leak. The FIDO Alliance reports passkeys block the credential phishing that drives most account takeover. This recipe implements both ceremonies using @simplewebauthn on the server and the browser's navigator.credentials API on the client. For the standard, see WebAuthn / FIDO2.
There are two ceremonies: registration (create and store a passkey) and authentication (prove possession of a stored passkey). Each is a two-step server round trip: the server issues options with a challenge, the browser performs the ceremony, the server verifies the result.
Relying party basics
The relying party (RP) is your site. Pin these once:
const rpName = 'Example';
const rpID = 'example.com'; // registrable domain, no scheme or port
const origin = 'https://example.com'; // exact origin the browser will report
The browser binds each credential to the RP ID, which is what makes passkeys phishing-resistant: a credential for example.com will not be offered on examp1e.com.
1. Registration: server issues options
import { generateRegistrationOptions } from '@simplewebauthn/server';
app.post('/webauthn/register/start', auth, async (req, res) => {
const user = req.user; // the already-signed-in user adding a passkey
const options = await generateRegistrationOptions({
rpName, rpID,
userName: user.email,
userID: new TextEncoder().encode(user.id),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await db.saveChallenge(user.id, options.challenge); // store to verify later
res.json(options);
});
2. Registration: browser creates the credential
import { startRegistration } from '@simplewebauthn/browser';
const options = await fetch('/webauthn/register/start', { method: 'POST' })
.then((r) => r.json());
const attResp = await startRegistration({ optionsJSON: options });
await fetch('/webauthn/register/finish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(attResp),
});
3. Registration: server verifies and stores
import { verifyRegistrationResponse } from '@simplewebauthn/server';
app.post('/webauthn/register/finish', auth, async (req, res) => {
const expectedChallenge = await db.getChallenge(req.user.id);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'verification_failed' });
}
const { credential } = verification.registrationInfo;
await db.saveCredential(req.user.id, {
credentialID: credential.id,
publicKey: credential.publicKey, // store the public key, never a secret
counter: credential.counter,
});
res.json({ verified: true });
});
You store only the public key and a signature counter. There is no secret on your server to steal, which is the core security improvement over passwords.
4. Authentication: server issues a challenge
import { generateAuthenticationOptions } from '@simplewebauthn/server';
app.post('/webauthn/login/start', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// allowCredentials can be empty for usernameless/discoverable passkeys
});
await db.saveLoginChallenge(req.sessionID, options.challenge);
res.json(options);
});
5. Authentication: browser signs, server verifies
import { startAuthentication } from '@simplewebauthn/browser';
const options = await fetch('/webauthn/login/start', { method: 'POST' }).then((r) => r.json());
const asseResp = await startAuthentication({ optionsJSON: options });
await fetch('/webauthn/login/finish', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(asseResp),
});
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
app.post('/webauthn/login/finish', async (req, res) => {
const cred = await db.getCredentialById(req.body.id);
if (!cred) return res.status(404).json({ error: 'unknown_credential' });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: await db.getLoginChallenge(req.sessionID),
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: cred.credentialID,
publicKey: cred.publicKey,
counter: cred.counter,
},
});
if (!verification.verified) return res.status(401).json({ error: 'auth_failed' });
await db.updateCounter(cred.credentialID, verification.authenticationInfo.newCounter);
// Establish your session here.
res.json({ verified: true });
});
What to get right
- Verify the origin and RP ID on the server. This is what stops a phished credential from a look-alike domain. Never trust client-reported values.
- Persist and check the signature counter to detect cloned authenticators, where supported.
- Offer discoverable (resident) credentials so users can log in without typing a username, the smoothest passkey experience.
- Keep a fallback. Let users register more than one passkey, and keep a recovery path for a lost device.
Passkeys are the practical form of phishing-resistant authentication discussed in the authentication guide. The protocol is specified in WebAuthn / FIDO2.
Related on Start with Identity
- ArticlePasswordless CIAM: Passkeys and WebAuthn for Customer Login
How to bring passwordless authentication to customer login: where passkeys fit, how they differ from magic links and OTP, the account-recovery problem, and a ro
- RecipeAdd login to a Next.js app with OIDC
A complete, copy-paste OpenID Connect login for the Next.js App Router using authorization code flow with PKCE, server-side token exchange, and httpOnly cookies
- BlogAmazon says 175 million customers now sign in with passkeys
Amazon reports more than 175 million customers have enabled passkeys, signing in six times faster, with passkeys now the default on mobile for anyone who has se
- BlogBlack Hat USA 2026 recap: passkeys get broken (twice), and AI agents get an identity perimeter
Our identity takeaways from Black Hat USA 2026: two independent passkey implementation attacks, a wave of AI agent identity and governance launches, an open sou
- BlogEntra ID makes passkeys the default, and retires SMS and voice in 2027
From September 2026 Entra ID auto-enables passkeys for users on SMS or voice. On 1 February 2027 those two methods stop working entirely, for every tenant, with
- GuideImplementing Passkeys in the Enterprise
A practical guide to deploying passkeys across your enterprise, covering WebAuthn integration, device attestation policies, account recovery workflows, and stra