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.