Skip to content
Security Intermediate Tutorial

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.

Emeka Okafor
Emeka Okafor
Security Editor · Jul 27, 2026 · 6 min read
Add Passkeys to Your Web App with Node.js and WebAuthn

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/server requires Node 20+; verified on 20.19.6)
  • Verified against @simplewebauthn/server 13.3.2, @simplewebauthn/browser 13.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://localhost as 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 Map stores for real persistence — one credentials table keyed by credential.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 credentialDeviceType and credentialBackedUp from registrationInfo to 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

  1. @simplewebauthn/server — simplewebauthn.dev
  2. @simplewebauthn/browser — simplewebauthn.dev
  3. SimpleWebAuthn releases — github.com
  4. Express — Node.js web application framework — expressjs.com
  5. Web Authentication: An API for accessing Public Key Credentials, Level 3 — w3.org
Emeka Okafor
Written by
Emeka Okafor · Security Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading