Add Passkeys to Your Web App with Node.js and WebAuthn
Ship Face ID and Touch ID sign-in using SimpleWebAuthn v13 and Express 5 in about 150 lines.
What you'll build
A working passwordless login: an Express backend that runs both WebAuthn ceremonies (registration and authentication) with SimpleWebAuthn, and a minimal frontend where users create a passkey and sign in with Face ID, Touch ID, or Windows Hello — no password field anywhere.
Prerequisites
- Node.js 20 or newer (
@simplewebauthn/serverrequires Node 20+; verified on 20.19.6) - Verified against
@simplewebauthn/server13.3.2,@simplewebauthn/browser13.3.0, and Express 5.2.1 - A device with a platform authenticator: macOS with Touch ID, Windows 11 with Windows Hello, or any phone — plus a current Chrome, Edge, or Safari
- No TLS setup needed: browsers treat
http://localhostas a secure context, so WebAuthn works in local dev
1. Scaffold the project
mkdir passkeys-demo && cd passkeys-demo
npm init -y
npm pkg set type=module
npm install express @simplewebauthn/server
mkdir public
2. Server setup and stores
Create server.js. The relying party (RP) values are the identity your passkeys are bound to — rpID is a domain (no scheme, no port), and origin is the exact URL the browser will report:
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const app = express();
app.use(express.json());
app.use(express.static('public'));
const rpName = 'Passkeys Demo';
const rpID = 'localhost';
const origin = 'http://localhost:3000';
// Demo stores — swap for a real database in production
const users = new Map(); // username -> { credentials: [] }
const challenges = new Map(); // username -> pending challenge
In production, change rpID to your domain (e.g. example.com) and origin to https://example.com — a passkey registered under one RP ID won't work under another.
3. The registration ceremony
Each ceremony is two endpoints: one generates options containing a random challenge, the other verifies the browser's signed response against that challenge — this is what makes responses unreplayable. Append to server.js:
app.post('/register/options', async (req, res) => {
const { username } = req.body;
const user = users.get(username) ?? { credentials: [] };
users.set(username, user);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: username,
attestationType: 'none',
// Stops a user enrolling the same authenticator twice
excludeCredentials: user.credentials.map((cred) => ({
id: cred.id,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: 'required', // discoverable credential = a real, syncable passkey
userVerification: 'preferred',
},
});
challenges.set(username, options.challenge);
res.json(options);
});
app.post('/register/verify', async (req, res) => {
const { username, response } = req.body;
try {
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenges.get(username),
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified) {
users.get(username).credentials.push(verification.registrationInfo.credential);
}
res.json({ verified: verification.verified });
} catch (err) {
res.status(400).json({ error: err.message });
} finally {
challenges.delete(username);
}
});
The stored credential object holds id, publicKey, counter, and transports — exactly the fields you'd persist per-credential in a real database.
4. The authentication ceremony
Same two-step shape. On verify, pass the stored credential so the library can check the signature against its public key. Append:
app.post('/login/options', async (req, res) => {
const { username } = req.body;
const user = users.get(username);
if (!user?.credentials.length) {
return res.status(404).json({ error: 'No passkeys registered for that username' });
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: user.credentials.map((cred) => ({
id: cred.id,
transports: cred.transports,
})),
userVerification: 'preferred',
});
challenges.set(username, options.challenge);
res.json(options);
});
app.post('/login/verify', async (req, res) => {
const { username, response } = req.body;
const credential = users
.get(username)
?.credentials.find((cred) => cred.id === response.id);
if (!credential) {
return res.status(400).json({ error: 'Unknown credential' });
}
try {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenges.get(username),
expectedOrigin: origin,
expectedRPID: rpID,
credential,
});
if (verification.verified) {
// Persist the new counter to detect cloned authenticators
credential.counter = verification.authenticationInfo.newCounter;
}
res.json({ verified: verification.verified });
} catch (err) {
res.status(400).json({ error: err.message });
} finally {
challenges.delete(username);
}
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
After verified: true you'd normally issue a session cookie — that part is unchanged from password login.
5. The frontend
Create public/index.html. @simplewebauthn/browser wraps navigator.credentials and handles all the base64url encoding; since v11 both methods take { optionsJSON }:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Passkeys demo</title>
<script src="https://unpkg.com/@simplewebauthn/browser@13/dist/bundle/index.umd.min.js"></script>
</head>
<body>
<input id="username" placeholder="username" autocomplete="username webauthn" />
<button id="register">Create a passkey</button>
<button id="login">Sign in with a passkey</button>
<p id="status"></p>
<script>
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser;
const status = (msg) => (document.getElementById('status').textContent = msg);
const post = async (url, body) => {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await r.json();
if (!r.ok) throw new Error(json.error);
return json;
};
document.getElementById('register').addEventListener('click', async () => {
const username = document.getElementById('username').value;
try {
const optionsJSON = await post('/register/options', { username });
const response = await startRegistration({ optionsJSON });
const { verified } = await post('/register/verify', { username, response });
status(verified ? 'Passkey created — now try signing in' : 'Registration failed');
} catch (err) {
status(err.message);
}
});
document.getElementById('login').addEventListener('click', async () => {
const username = document.getElementById('username').value;
try {
const optionsJSON = await post('/login/options', { username });
const response = await startAuthentication({ optionsJSON });
const { verified } = await post('/login/verify', { username, response });
status(verified ? `Signed in as ${username} ✅` : 'Authentication failed');
} catch (err) {
status(err.message);
}
});
</script>
</body>
</html>
Verify it works
Start the server:
node server.js
Listening on http://localhost:3000
Open http://localhost:3000, type a username, and click Create a passkey. Your OS prompts for Face ID / Touch ID / Windows Hello, then the page shows Passkey created — now try signing in. Click Sign in with a passkey, approve the biometric prompt again, and you'll see Signed in as <username> ✅.
You can also sanity-check the options endpoint directly:
curl -s -X POST localhost:3000/register/options \
-H 'Content-Type: application/json' -d '{"username":"marc"}'
Expect JSON containing a challenge, "rp":{"name":"Passkeys Demo","id":"localhost"}, and pubKeyCredParams listing algorithms -8, -7, and -257.
Troubleshooting
The RP ID "example.com" is invalid for this domain — your server's rpID doesn't match the domain the page is served from. The RP ID must equal the site's domain or a registrable parent of it (localhost in dev, example.com for app.example.com in prod).
Unexpected registration response origin "http://localhost:3000", expected "https://localhost:3000" — the origin constant doesn't exactly match the browser's origin, scheme and port included. Common when you set https:// locally or forget a non-standard port.
The authenticator was previously registered — not a bug: excludeCredentials is doing its job because that username already has a passkey on this device. Sign in instead, or use a different username to test registration again.
NotAllowedError: The operation either timed out or was not allowed — the user dismissed the OS prompt, or the 60-second default timeout elapsed. Just retry; only surface a soft error in the UI.
Next steps
- Swap the
Mapstores for real persistence — one credentials table keyed bycredential.id, plus challenge storage with a short TTL (or an httpOnly session) - Add conditional UI (
useBrowserAutofill: true) so passkeys appear in the username field's autofill, no button needed - Store
credentialDeviceTypeandcredentialBackedUpfromregistrationInfoto distinguish synced passkeys from device-bound ones - Read the WebAuthn Level 3 spec for what the ceremonies actually sign, and SimpleWebAuthn's example project for a fuller reference implementation
Sources & further reading
- @simplewebauthn/server — simplewebauthn.dev
- @simplewebauthn/browser — simplewebauthn.dev
- SimpleWebAuthn releases — github.com
- Express — Node.js web application framework — expressjs.com
- Web Authentication: An API for accessing Public Key Credentials, Level 3 — w3.org
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.