Passkeys for Web Apps: Passwordless Login with WebAuthn in Node.js
Add passkey sign-up and sign-in to an Express 5 app with SimpleWebAuthn v14: registration and authentication flows, what to store, browser code, autofill and production advice.

Mohit Malaya Nandy
Software Engineer & Product Builder

On this page
Passwords are the weakest part of most apps. People reuse them, phishing sites collect them, and every database breach leaks them. Passkeys replace them with something users already know how to do: unlock their phone or laptop with a fingerprint, face or PIN.
In this guide you will add passkey sign-up and sign-in to a Node.js and Express 5 app with SimpleWebAuthn (v14), the most widely used WebAuthn library for JavaScript. It is a natural next step after my JWT and refresh token guide. I ran the server and tested its options, verification and error paths; you need a real device or browser to complete a full sign-in.
How passkeys work#
Passkeys use public-key cryptography, defined by the WebAuthn standard. Here is the idea without the maths:
- When a user creates a passkey, their device makes a key pair. The private key never leaves the device (or their password manager). Your server receives and stores only the public key.
- To sign in, your server sends a random challenge. The device signs it with the private key, after the user confirms with a fingerprint, face or PIN.
- Your server checks the signature with the stored public key. If it matches, the user is in.
| Passwords | Passkeys | |
|---|---|---|
| What your database stores | A password hash (still a target) | A public key (useless to attackers) |
| Phishing | Users can type it into a fake site | Bound to your domain; does not work on fake sites |
| Reuse across sites | Very common | Impossible: one key pair per site |
| User effort | Remember and type | Fingerprint, face or PIN |
Each step has two halves: the server creates options (including the challenge), the browser asks the device to act on them, and the server verifies the result. SimpleWebAuthn gives you one function for each half.
Step 1: Set up the project#
mkdir passkeys-demo
cd passkeys-demo
npm init -y
npm pkg set type=module
npm install express express-session @simplewebauthn/server
mkdir publicexpress-session stores the challenge between the "options" and "verify" requests, and later the signed-in user.
Step 2: Decide what to store#
For each passkey, the SimpleWebAuthn docs recommend storing:
| Field | Type | Why |
|---|---|---|
| id | Base64URL string (indexed) | Identifies the passkey at sign-in |
| publicKey | Bytes | Verifies signatures |
| counter | Number | Helps detect cloned authenticators |
| transports | Array (optional) | Hints such as usb, nfc or internal |
| deviceType, backedUp | String, boolean | Whether it syncs across devices |
| userId | Foreign key | Links the passkey to your user |
Each user also gets a random webauthnUserID. Use random bytes, not the email or database ID, so the passkey reveals nothing personal. For this demo, everything lives in memory:
import { randomUUID } from 'node:crypto';
// In-memory storage for the demo. Use real database tables in production.
const users = new Map();
const passkeys = new Map();
export const findUserByEmail = (email) => [...users.values()].find((u) => u.email === email);
export function createUser(email, webauthnUserID) {
const user = { id: randomUUID(), email, webauthnUserID };
users.set(user.id, user);
return user;
}
export const getPasskeys = (userId) => [...passkeys.values()].filter((pk) => pk.userId === userId);
export const savePasskey = (pk) => passkeys.set(pk.id, pk);
export const findPasskey = (id) => passkeys.get(id);
export function updateCounter(id, counter) {
const pk = passkeys.get(id);
if (pk) pk.counter = counter;
}Step 3: Write the server#
The server has four routes: options and verify, for both registration and sign-in.
import express from 'express';
import session from 'express-session';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { findUserByEmail, createUser, getPasskeys, savePasskey, findPasskey, updateCounter } from './db.js';
const rpName = 'My App';
const rpID = process.env.RP_ID ?? 'localhost';
const origin = process.env.ORIGIN ?? 'http://localhost:3000';
const app = express();
app.use(express.json({ limit: '20kb' }));
app.use(express.static('public'));
app.use(
session({
secret: process.env.SESSION_SECRET ?? 'dev-only-secret',
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' },
})
);
// 1. Registration: send options to the browser
app.post('/passkeys/register/options', async (req, res) => {
const email = String(req.body?.email ?? '').trim().toLowerCase();
if (!email.includes('@')) return res.status(400).json({ error: 'Valid email required' });
const existing = findUserByEmail(email);
// Only the signed-in owner may add a passkey to an existing account.
if (existing && req.session.userId !== existing.id) {
return res.status(409).json({ error: 'Sign in to add a passkey to this account' });
}
// Random, stable ID for WebAuthn. Never use the email or database ID here.
const webauthnUserID = existing?.webauthnUserID ?? isoBase64URL.fromBuffer(crypto.getRandomValues(new Uint8Array(32)));
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: email,
userID: isoBase64URL.toBuffer(webauthnUserID),
attestationType: 'none',
excludeCredentials: existing ? getPasskeys(existing.id).map((pk) => ({ id: pk.id, transports: pk.transports })) : [],
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
});
req.session.registration = { email, webauthnUserID, challenge: options.challenge };
res.json(options);
});
// 2. Registration: verify the browser's response and save the passkey
app.post('/passkeys/register/verify', async (req, res) => {
const pending = req.session.registration;
if (!pending) return res.status(400).json({ error: 'No registration in progress' });
delete req.session.registration; // a challenge is single-use
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: pending.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch {
return res.status(400).json({ error: 'Registration failed' });
}
if (!verification.verified) return res.status(400).json({ error: 'Registration failed' });
// Create the account only now that the passkey is proven.
const user = findUserByEmail(pending.email) ?? createUser(pending.email, pending.webauthnUserID);
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
savePasskey({
id: credential.id,
userId: user.id,
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
counter: credential.counter,
transports: credential.transports,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Registration failed' });
req.session.userId = user.id;
res.json({ verified: true });
});
});
// 3. Sign-in: send options (no allowCredentials, so the user can pick any passkey for this site)
app.post('/passkeys/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
req.session.authChallenge = options.challenge;
res.json(options);
});
// 4. Sign-in: verify the signature with the stored public key
app.post('/passkeys/login/verify', async (req, res) => {
const expectedChallenge = req.session.authChallenge;
delete req.session.authChallenge;
const passkey = req.body?.id && findPasskey(req.body.id);
if (!expectedChallenge || !passkey) return res.status(400).json({ error: 'Login failed' });
let verification;
try {
verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: passkey.id,
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports,
},
});
} catch {
return res.status(400).json({ error: 'Login failed' });
}
if (!verification.verified) return res.status(400).json({ error: 'Login failed' });
updateCounter(passkey.id, verification.authenticationInfo.newCounter);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Login failed' });
req.session.userId = passkey.userId;
res.json({ verified: true });
});
});
app.listen(3000, () => console.log('Running on http://localhost:3000'));The important details
rpIDandoriginare your domain (example.com) and the full URL (https://example.com, no trailing slash). Passkeys are bound to therpID, which is what makes them phishing-resistant.- Challenges are single-use. Each one is stored in the session and deleted as soon as it is checked, which blocks replay attacks.
attestationType: 'none'skips proof of which device model was used. Most consumer apps don't need it.residentKey: 'preferred'creates a discoverable passkey, so users can sign in without typing their email first.- Session regeneration after login issues a new session ID, preventing session fixation.
- Generic errors ("Login failed") avoid telling attackers why a request was rejected.
Step 4: The browser side#
The browser library wraps the WebAuthn API and handles the binary encoding for you (browser package docs). In v14, pass the server's options as optionsJSON:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Passkeys demo</title></head>
<body>
<input id="email" type="email" placeholder="you@example.com" autocomplete="username webauthn">
<button id="register">Create a passkey</button>
<button id="login">Sign in with a passkey</button>
<p id="status"></p>
<script type="module">
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
} from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@14.0.0/+esm';
const status = document.querySelector('#status');
const post = (url, body) =>
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!browserSupportsWebAuthn()) status.textContent = 'This browser does not support passkeys.';
document.querySelector('#register').onclick = async () => {
try {
const optionsRes = await post('/passkeys/register/options', { email: document.querySelector('#email').value });
const optionsJSON = await optionsRes.json();
if (!optionsRes.ok) throw new Error(optionsJSON.error);
const attestation = await startRegistration({ optionsJSON }); // the device prompt appears here
const verifyRes = await post('/passkeys/register/verify', attestation);
status.textContent = verifyRes.ok ? 'Passkey created. You are signed in.' : 'Registration failed.';
} catch (err) {
status.textContent = err.name === 'NotAllowedError' ? 'Cancelled.' : err.message;
}
};
document.querySelector('#login').onclick = async () => {
try {
const optionsJSON = await (await post('/passkeys/login/options', {})).json();
const assertion = await startAuthentication({ optionsJSON });
const verifyRes = await post('/passkeys/login/verify', assertion);
status.textContent = verifyRes.ok ? 'Signed in!' : 'Sign-in failed.';
} catch (err) {
status.textContent = err.name === 'NotAllowedError' ? 'Cancelled.' : err.message;
}
};
</script>
</body>
</html>In a real app built with a bundler, npm install @simplewebauthn/browser and import it normally instead of from the CDN.
Step 5: Try it#
node server.js- Open
http://localhost:3000, enter an email and click Create a passkey. Your device asks for your fingerprint, face or PIN. - Click Sign in with a passkey. The browser lists the passkeys saved for this site.
When I tested the server, the options routes returned valid WebAuthn options, a fake response was rejected with "Registration failed", a second verify with the same challenge was rejected with "No registration in progress", and an invalid email returned a 400.
Bonus: passkey autofill#
Passkeys can appear in the browser's autofill menu, like saved passwords. Add webauthn to the input's autocomplete attribute (as in the HTML above), then start a conditional request when the page loads:
import { browserSupportsWebAuthnAutofill, startAuthentication } from '@simplewebauthn/browser';
if (await browserSupportsWebAuthnAutofill()) {
const optionsJSON = await (await post('/passkeys/login/options', {})).json();
const assertion = await startAuthentication({ optionsJSON, useBrowserAutofill: true });
// Runs when the user picks a passkey from the autofill menu
await post('/passkeys/login/verify', assertion);
}Going to production#
- HTTPS is required, and
rpIDmust be your real domain. Passkeys made forlocalhostdo not work on your live site. - Store passkeys in a real database, with an index on the passkey
id, and a production session store (such as Redis) instead of the default memory store. - Use a strong
SESSION_SECRETfrom an environment variable. - Let users manage passkeys: list them with their device type and creation date, add more, and remove old ones.
- Plan account recovery before you remove passwords: a second passkey, or a verified email or recovery code.
- Verify email addresses for new accounts if the email matters to your app.
- Rate-limit the options and verify routes, as you would any login endpoint (OWASP authentication guidance).
Frequently asked questions#
What is a passkey?
A passkey is a login credential that replaces a password. It is a key pair: the private key stays on the user's device or password manager, and your server stores only the public key. The user unlocks it with a fingerprint, face scan or device PIN.
Are passkeys more secure than passwords?
Yes. There is no shared secret to steal from your database, nothing to reuse across sites, and passkeys are bound to your domain, so they cannot be typed into a phishing site.
What happens if a user loses their phone?
Passkeys stored in iCloud Keychain, Google Password Manager or another password manager sync to the user's other devices. Still, offer a recovery path, such as a second passkey or email-based recovery, and let users register several passkeys.
Do passkeys work on localhost?
Yes. WebAuthn treats http://localhost as a secure context, so you can develop locally with rpID set to localhost. In production you must use HTTPS and your real domain.
Can I use passkeys together with JWT authentication?
Yes. A passkey only proves who the user is at login. After verification, issue whatever session you already use: a session cookie, as in this guide, or the JWT access and refresh tokens from my MERN authentication guide.
Official references#
- @simplewebauthn/server and @simplewebauthn/browser documentation
- passkeys.dev: developer resources from the FIDO Alliance and W3C community
- Web Authentication API, MDN
- Web Authentication specification, W3C
- Authentication Cheat Sheet, OWASP
Found this useful? You can support my writing on GitHub Sponsors or with a coffee.


