Skip to content

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.

PasskeysBy Rushi Patel, Senior iOS developerUpdated 9 November 20269 min read

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.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
What does a passkey server have to do?

Four things. Make registration options with a fresh challenge, verify the attestation the browser sends back and store the public key, make sign-in options with another fresh challenge, and verify the assertion against the stored key. Every check is in the WebAuthn spec; a library such as SimpleWebAuthn does them for you.

Which Node library verifies WebAuthn responses?

SimpleWebAuthn (the @simplewebauthn/server package) is the most used. Version 13 needs Node 22 or newer and runs in Deno, Bun and Cloudflare Workers too. It generates the options, checks challenge, origin, RP ID hash, flags and signature, and returns the public key to store.

Where should I keep the challenge between the options and the verify call?

Server side, tied to the person's session or to a short-lived signed value, for one attempt only. Expire it after a few minutes and delete it once used, so a captured response cannot be replayed.

What should I store for each passkey?

The credential id (base64url, indexed), the public key as bytes, the user it belongs to, the signature counter, the transports, and whether it is synced (device type and backed up). Never store a private key; the server never sees one.

Why does verification fail with "Response counter value 0 was lower than expected"?

Your stored counter is above 0 and the authenticator sent 0 or a lower number. Synced passkeys from Apple and Google usually send 0 every time, which the library accepts when the stored counter is also 0. Store the counter the registration returned, and update it with newCounter after every sign-in.

Do I need attestation for passkeys?

Almost never. attestationType none is the default and the right choice for a consumer site. Synced passkeys do not give a useful attestation, and asking for one adds a prompt on some platforms.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.