A passkey server in Node.js, route by route
Build the four WebAuthn routes in Node with SimpleWebAuthn 13: options, registration verify, sign-in verify, what to store, the counter, and every verify error with its fix.
A passkey server is four calls: make registration options, verify the registration, make sign-in options, verify the sign-in. The browser and the phone do the cryptography; your server makes a random challenge, sends it out, and checks what comes back. This page builds those four routes in Node with SimpleWebAuthn version 13, the library most Node passkey servers use, and lists every error it throws with the cause.
npm install @simplewebauthn/server @simplewebauthn/browser # Node 22 or newer
The four routes at a glance
| Route | Library call | Store or read | Browser call |
|---|---|---|---|
| Registration options | generateRegistrationOptions() |
Store the challenge | startRegistration() |
| Registration verify | verifyRegistrationResponse() |
Store id, public key, counter, transports | |
| Sign-in options | generateAuthenticationOptions() |
Store the challenge | startAuthentication() |
| Sign-in verify | verifyAuthenticationResponse() |
Read the passkey by id, update the counter |
The settings every route shares:
const rpID = "example.com"; // your domain, or a parent of the page's domain
const rpName = "Example"; // shown in some prompts
const origin = ["https://example.com", "https://www.example.com"]; // every page that shows the prompt
rpID is permanent: passkeys made for app.example.com never work on www.example.com, so pick the parent domain if you have more than one. origin must list every page origin exactly, scheme and port included.
1. Registration options
The user is already signed in some other way (a password, an email code) and is adding a passkey.
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from "@simplewebauthn/server";
app.post("/passkeys/register/options", requireUser, async (req, res) => {
const existing = await db.passkeys.forUser(req.user.id);
const options = await generateRegistrationOptions({
rpName, rpID,
userName: req.user.email, // what the password manager shows
userID: new TextEncoder().encode(req.user.webauthnId), // bytes, not your email; at most 64
attestationType: "none",
excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
});
req.session.challenge = options.challenge; // one attempt, server side
res.json(options);
});
What the defaults are in version 13, so you know what you are not setting: timeout 60000 (60 seconds), attestationType "none", algorithms [-8, -7, -257] (Ed25519, ES256, RS256), and residentKey and userVerification both "preferred". userID must be bytes; a string throws “String values for userID are no longer supported”. Give each user a random id of their own rather than an email, because the authenticator stores it and hands it back at sign-in.
excludeCredentials stops the same phone saving two passkeys for one account. The browser answers InvalidStateError when it hits one; see WebAuthn errors.
2. Verify the registration
app.post("/passkeys/register/verify", requireUser, async (req, res) => {
const expectedChallenge = req.session.challenge;
delete req.session.challenge; // used once, whatever happens next
let v;
try {
v = await verifyRegistrationResponse({
response: req.body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID,
requireUserVerification: false, // match userVerification: "preferred"
});
} catch (err) {
return res.status(400).json({ error: err.message });
}
if (!v.verified) return res.status(400).end();
const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;
await db.passkeys.insert({
id: credential.id, // base64url string, index it
publicKey: credential.publicKey, // Uint8Array, store as bytes
counter: credential.counter,
transports: credential.transports,
userId: req.user.id,
deviceType: credentialDeviceType, // "singleDevice" or "multiDevice"
backedUp: credentialBackedUp, // true for a synced passkey
});
res.json({ ok: true });
});
requireUserVerification defaults to true in the verify calls. If your options say "preferred" and the verify call keeps the default, a security key without a PIN registers in the browser and then fails on your server. Make the two agree: "required" and true, or "preferred" and false.
credentialBackedUp tells you the passkey is synced (iCloud Keychain, Google Password Manager, a password manager), so losing the phone does not lose it. A site can use it to suggest a second passkey when the only one is not backed up.
3. Sign-in options
app.post("/passkeys/signin/options", async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: [], // empty: the user picks any passkey for this site
userVerification: "preferred",
});
req.session.challenge = options.challenge;
res.json(options);
});
An empty allowCredentials is what makes “sign in with a passkey” work without typing a username, and it is required for passkey autofill. Fill it only when the user has already typed who they are and you want to limit the prompt to that account’s passkeys.
4. Verify the sign-in
app.post("/passkeys/signin/verify", async (req, res) => {
const expectedChallenge = req.session.challenge;
delete req.session.challenge;
const passkey = await db.passkeys.byId(req.body.id);
if (!passkey) return res.status(400).json({ error: "Unknown passkey" });
let v;
try {
v = await verifyAuthenticationResponse({
response: req.body, expectedChallenge, expectedOrigin: origin, expectedRPID: rpID,
credential: { id: passkey.id, publicKey: passkey.publicKey, counter: passkey.counter, transports: passkey.transports },
requireUserVerification: false,
});
} catch (err) {
return res.status(400).json({ error: err.message });
}
if (!v.verified) return res.status(400).end();
await db.passkeys.update(passkey.id, { counter: v.authenticationInfo.newCounter, lastUsedAt: new Date() });
req.session.regenerate(() => { req.session.userId = passkey.userId; res.json({ ok: true }); });
});
The user comes from the stored passkey, never from anything the browser claims. Look the passkey up by req.body.id, and if your database returns a Buffer for the public key, wrap it in new Uint8Array(...) first.
The browser side
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
const options = await (await fetch("/passkeys/register/options", { method: "POST" })).json();
const response = await startRegistration({ optionsJSON: options });
await fetch("/passkeys/register/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(response) });
Sign-in is the same with startAuthentication({ optionsJSON }). Both take the options as optionsJSON in version 13; older examples pass the options directly and break on upgrade.
Every error the verify calls throw
These are the messages SimpleWebAuthn 13 throws, what causes each, and the fix.
| Message starts with | Cause | Fix |
|---|---|---|
Unexpected registration response challenge or authentication response challenge |
The stored challenge is from another attempt: two tabs, a reload, or a session that did not persist | One challenge per attempt; check the session cookie reaches the verify route (sameSite, secure) |
Unexpected ... origin "https://www.example.com" |
The page origin is not in expectedOrigin |
Add every origin, including www, ports in development, and android:apk-key-hash:... for Android apps |
Unexpected RP ID hash |
The passkey was made for another rpID |
Use the same rpID everywhere; a passkey cannot move domains |
User verification was required or User verification required |
requireUserVerification is true and no PIN or biometric was used |
Set it to match the options’ userVerification |
User not present during authentication |
The authenticator reported no user presence | Rare; let the user retry |
Response counter value N was lower than expected M |
The counter went backwards from a non-zero stored value | Update the counter after every sign-in; treat a real drop as a possibly cloned key |
Unexpected public key alg |
The key uses an algorithm not in supportedAlgorithmIDs |
Leave the default, or include -7 and -257 |
Credential ID was not base64url-encoded |
The body was changed on the way, often by a hand-written serializer | Send the browser library’s response as it is, with JSON.stringify |
Unsupported Attestation Format |
You asked for attestation and got a format the library does not know | Use attestationType: "none" |
Two details that cause most bugs
The counter. Synced passkeys from Apple and Google usually send a counter of 0 every time. The library only checks the counter when either the stored value or the new value is above 0, so a 0 stays a 0 and passes. A security key counts up, and you must save newCounter after each sign-in or the next one fails.
Origins for apps. A passkey made in an Android app carries android:apk-key-hash:<base64url of the signing certificate's SHA-256> as its origin, not your website. Add that string to expectedOrigin for each app. iOS apps send your website origin, https:// plus the associated domain.
With SignOps
SignOps Passkeys runs this server for you, on the same library (@simplewebauthn/server 13), under your own domain as rpID. Registration asks for no attestation, sets residentKey and userVerification to "preferred", and fills excludeCredentials from the user’s passkeys (up to 10 per user). Sign-in challenges travel in a signed session that lasts 5 minutes and is spent once, after the signature checks out, so a copied request cannot sign in twice. The counter is updated on every sign-in, and the registration tells you whether the passkey is synced. Origins are your sign-in pages plus the Android app origins and the iOS domain you list in the dashboard.
Your server makes a registration token, your page calls passkeys.register() or passkeys.signIn() from @signops/web, and your server confirms the signops_token it gets back before starting its own session. Steps: passkeys on your website and in Android and iOS apps.
Sources: SimpleWebAuthn: server package, SimpleWebAuthn: browser package, W3C: Web Authentication Level 3, registering a new credential and verifying an authentication assertion.