Tutorial

Passkeys in Node.js: Add Passwordless Login to an Express App

Build passkey registration, usernameless login, and browser autofill with SimpleWebAuthn, plus the security checks most tutorials skip.

·
16 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Passkeys in Node.js: Add Passwordless Login to an Express App

Passwords are the weakest part of most login systems: they get reused, phished, and leaked. Passkeys replace them with a cryptographic key pair. Your server stores only a public key, so a database breach gives attackers nothing they can replay, and the browser refuses to use a passkey on the wrong website, which makes phishing far harder.

In this guide you will build working passkey registration and login in Express, including usernameless sign-in, browser autofill, and the security checks that most tutorials skip. By the end you will have:

  • Four WebAuthn endpoints (registration and login, each with an options step and a verify step) plus session endpoints

  • A small vanilla JavaScript frontend with passkey autofill

  • A clear list of what to change before you ship to production

The demo keeps data in memory so you can run it without a database. A production SQL schema is included, and the store is a thin layer you can swap out.

How passkeys work in 60 seconds

Passkeys use the WebAuthn standard. There are only two operations, called ceremonies:

  1. Registration. Your server sends a random challenge. The browser asks the user's authenticator (a phone, laptop, password manager, or security key) to create a new key pair for your site. The private key never leaves the authenticator. The browser returns the public key and a credential ID, and your server verifies the response and stores them.

  2. Authentication. Your server sends a fresh challenge. The authenticator signs it with the private key after a biometric or PIN check. Your server verifies the signature against the stored public key.

Four terms come up constantly:

  • Relying party ID (rpID): the domain the passkey is bound to, such as example.com. No scheme, no port.

  • Origin: the full origin of your page, such as https://example.com. The server checks that responses came from it.

  • Challenge: a random, single-use value that stops replayed responses.

  • Credential ID and public key: what you store for each passkey.

Because the rpID and origin are checked, a passkey created for example.com simply will not work on examp1e.com. That is where the phishing resistance comes from.

Prerequisites

  • Node.js 22 or newer (SimpleWebAuthn 14 requires it) and basic Express knowledge

  • A device or browser with passkey support (any current Chrome, Safari, Edge, or Firefox)

  • A secure context: WebAuthn needs HTTPS, except on localhost

Project setup

mkdir passkeys-express && cd passkeys-express
npm init -y
npm pkg set type=module
npm install express express-session @simplewebauthn/server@14
mkdir public

The project will have this layout:

passkeys-express/
├── config.js
├── store.js
├── server.js
└── public/
    ├── index.html
    └── app.js

The browser half of SimpleWebAuthn is loaded from a CDN in the frontend, so there is nothing else to install. Pin your versions in a real project, because the library's API has changed between major versions. This guide targets version 14. Compared with 13.3.x, it only raised the minimum Node version to 22, so the API calls below are the same on 13.3.x.

Configuration

// config.js
const isProd = process.env.NODE_ENV === 'production';

if (isProd && !process.env.SESSION_SECRET) {
  throw new Error('SESSION_SECRET must be set in production');
}

export const config = {
  isProd,
  port: Number(process.env.PORT ?? 3000),
  rpName: 'Passkeys Demo',
  // The domain passkeys are bound to: no scheme, no port.
  rpID: process.env.RP_ID ?? 'localhost',
  // The exact origin of your frontend: scheme + host + port.
  origin: process.env.ORIGIN ?? 'http://localhost:3000',
  sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me',
};

Two of these values cause most of the bugs people hit. The rpID must equal your page's domain or a registrable parent of it. If your app lives at app.example.com, setting rpID to example.com lets the same passkeys work across subdomains. Decide this before launch: a passkey created under one rpID cannot be moved to another. The origin must match exactly what the browser shows, including the port in development.

The credential store

Each passkey needs a handful of fields: the credential ID, the owning user, the public key (as raw bytes, never as a short fixed-length string, since version 14 can register larger post-quantum keys on runtimes that support them), a signature counter, and the transports the authenticator reported. We also keep the device type and backup state so the UI can later show whether a passkey is synced or tied to one device.

// store.js - in-memory, for the demo only. Data is lost on restart.
const usersByName = new Map(); // username -> { id, username }
const usersById = new Map(); // id -> { id, username }
const passkeys = new Map(); // credential id (base64url) -> passkey record

export const findUserByUsername = (username) => usersByName.get(username);
export const findUserById = (id) => usersById.get(id);

export function createUser(user) {
  if (usersByName.has(user.username)) return false;
  usersByName.set(user.username, user);
  usersById.set(user.id, user);
  return true;
}

export const listPasskeys = (userId) =>
  [...passkeys.values()].filter((p) => p.userId === userId);

export const findPasskey = (id) => passkeys.get(id);
export const savePasskey = (passkey) => passkeys.set(passkey.id, passkey);

export function updatePasskey(id, changes) {
  const passkey = passkeys.get(id);
  if (passkey) Object.assign(passkey, changes);
}

For production, use a real database. Here is a Postgres schema that maps directly to the functions above:

CREATE TABLE users (
  id         TEXT PRIMARY KEY,
  username   TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE passkeys (
  id           TEXT PRIMARY KEY,            -- credential ID, base64url
  user_id      TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  public_key   BYTEA NOT NULL,
  counter      BIGINT NOT NULL DEFAULT 0,
  transports   TEXT[] NOT NULL DEFAULT '{}',
  device_type  TEXT NOT NULL,
  backed_up    BOOLEAN NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);

CREATE INDEX passkeys_user_id_idx ON passkeys (user_id);

Note that the user's id is a random UUID, not their email or username. WebAuthn stores this value (the user handle) inside the passkey on the user's device, so it must never contain personal data.

The Express server

We will build server.js in four pieces: setup and helpers, registration, login, and session endpoints.

Setup and helpers

// server.js
import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { config } from './config.js';
import * as store from './store.js';

const app = express();
app.use(express.json());
app.use(express.static('public'));

// Needed for secure cookies when running behind a proxy or load balancer.
if (config.isProd) app.set('trust proxy', 1);

app.use(
  session({
    name: 'sid',
    secret: config.sessionSecret,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      sameSite: 'lax',
      secure: config.isProd,
      maxAge: 7 * 24 * 60 * 60 * 1000,
    },
  })
);

const CEREMONY_TTL_MS = 5 * 60 * 1000;

// Remember the challenge (and who it was issued for) in the user's session.
function startCeremony(req, data) {
  req.session.ceremony = { ...data, expiresAt: Date.now() + CEREMONY_TTL_MS };
}

// Read AND delete in one step, so a challenge can never be used twice.
function takeCeremony(req, type) {
  const ceremony = req.session.ceremony;
  delete req.session.ceremony;
  if (!ceremony || ceremony.type !== type || ceremony.expiresAt < Date.now()) {
    return null;
  }
  return ceremony;
}

// Issue a fresh session ID on login to prevent session fixation.
function logIn(req, userId) {
  return new Promise((resolve, reject) => {
    req.session.regenerate((err) => {
      if (err) return reject(err);
      req.session.userId = userId;
      req.session.save((err2) => (err2 ? reject(err2) : resolve()));
    });
  });
}

function normalizeUsername(input) {
  if (typeof input !== 'string') return null;
  const username = input.trim().toLowerCase();
  return /^[a-z0-9._-]{3,32}$/.test(username) ? username : null;
}

The challenge lives in the server-side session, not in the browser. takeCeremony is the most important helper here. It makes every challenge single-use and short-lived, and it refuses to use a registration challenge for a login (or the other way around).

Registration: generate options

This one endpoint serves two cases. A signed-out visitor is signing up and must choose a username. A signed-in user is adding another passkey to their own account. The server decides which case applies from the session, never from anything the client claims.

app.post('/api/register/options', async (req, res) => {
  let user = req.session.userId ? store.findUserById(req.session.userId) : null;
  let isNew = false;

  if (!user) {
    const username = normalizeUsername(req.body?.username);
    if (!username) {
      return res
        .status(400)
        .json({ error: 'Use 3-32 characters: letters, numbers, dot, dash or underscore.' });
    }
    if (store.findUserByUsername(username)) {
      return res.status(409).json({ error: 'That username is already taken.' });
    }
    user = { id: crypto.randomUUID(), username };
    isNew = true;
  }

  const options = await generateRegistrationOptions({
    rpName: config.rpName,
    rpID: config.rpID,
    userName: user.username,
    userID: new TextEncoder().encode(user.id),
    attestationType: 'none',
    // Stop the same authenticator from registering twice for this account.
    excludeCredentials: store.listPasskeys(user.id).map((p) => ({
      id: p.id,
      transports: p.transports,
    })),
    authenticatorSelection: {
      residentKey: 'required', // discoverable credential = a real passkey
      userVerification: 'required', // biometric or PIN, every time
    },
  });

  startCeremony(req, { type: 'registration', challenge: options.challenge, user, isNew });
  res.json(options);
});

A few choices worth understanding:

  • Never add a passkey to an existing username for a signed-out request. If you did, anyone could type a victim's username and attach their own passkey to that account. That is an account takeover. Adding a passkey to an existing account requires an existing session.

  • residentKey: 'required' creates a discoverable credential. That is what lets the user sign in later without typing a username.

  • userVerification: 'required' means the authenticator must check a fingerprint, face, or PIN. This is what makes a passkey a true two-factor login (something you have plus something you are or know) with no extra step.

  • attestationType: 'none' skips hardware attestation. Consumer sites almost never need it, and requesting it triggers extra prompts.

  • excludeCredentials tells the browser to refuse a device that already has a passkey for this account, which avoids confusing duplicates.

Registration: verify the response

app.post('/api/register/verify', async (req, res) => {
  const ceremony = takeCeremony(req, 'registration');
  if (!ceremony) {
    return res.status(400).json({ error: 'Registration expired. Please start again.' });
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: req.body?.response,
      expectedChallenge: ceremony.challenge,
      expectedOrigin: config.origin,
      expectedRPID: config.rpID,
      requireUserVerification: true,
    });
  } catch (err) {
    console.error('Registration verification failed:', err);
    return res.status(400).json({ error: 'Could not verify the passkey.' });
  }

  if (!verification.verified) {
    return res.status(400).json({ error: 'Could not verify the passkey.' });
  }

  const { credential, credentialDeviceType, credentialBackedUp } =
    verification.registrationInfo;

  // Reject a credential ID that is already registered to anyone. IDs come from
  // the authenticator, so a hostile client could submit someone else's.
  if (store.findPasskey(credential.id)) {
    return res.status(409).json({ error: 'That passkey is already registered.' });
  }

  // Create the account only now, after the passkey is proven to work.
  if (ceremony.isNew && !store.createUser(ceremony.user)) {
    return res.status(409).json({ error: 'That username was just taken.' });
  }

  store.savePasskey({
    id: credential.id,
    userId: ceremony.user.id,
    publicKey: credential.publicKey,
    counter: credential.counter,
    transports: credential.transports ?? [],
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
    createdAt: Date.now(),
    lastUsedAt: null,
  });

  await logIn(req, ceremony.user.id);
  res.json({ verified: true });
});

verifyRegistrationResponse does the heavy lifting: it checks that the challenge matches, that the origin and rpID are yours, that the response type is correct, and that the authenticator performed user verification. Your job is to supply the expected values from your own trusted state, never from the request body.

Notice that the account is created only after verification succeeds, and the username check runs again at that point. Two people can start signing up with the same username at the same moment, and only the first to finish should win.

The credential ID check matters too. Credential IDs are chosen by the authenticator, so a hostile client could submit one that already belongs to another user, and a careless store would overwrite that user's passkey. The WebAuthn spec tells relying parties to reject a credential ID that is already registered, and the primary key in the SQL schema above enforces the same rule.

Login: generate options

app.post('/api/login/options', async (req, res) => {
  // No allowCredentials list: the authenticator offers the user's passkeys
  // for this site, so no username is needed.
  const options = await generateAuthenticationOptions({
    rpID: config.rpID,
    userVerification: 'required',
  });

  startCeremony(req, { type: 'authentication', challenge: options.challenge });
  res.json(options);
});

Leaving out allowCredentials is what enables usernameless login. It also means this endpoint reveals nothing about which usernames exist, so login avoids the account-enumeration problem of username-and-password forms. (Signup still has to tell people when a username is taken.)

Login: verify the response

app.post('/api/login/verify', async (req, res) => {
  const ceremony = takeCeremony(req, 'authentication');
  if (!ceremony) {
    return res.status(400).json({ error: 'Sign-in expired. Please try again.' });
  }

  const response = req.body?.response;
  const passkey = store.findPasskey(response?.id);

  // Defence in depth: the user handle returned by the authenticator must match
  // the passkey's owner (the spec asks for this check on usernameless logins).
  const userHandle = response?.response?.userHandle;
  const handleMismatch =
    passkey &&
    typeof userHandle === 'string' &&
    isoBase64URL.toUTF8String(userHandle) !== passkey.userId;

  if (!passkey || handleMismatch) {
    return res.status(400).json({ error: 'Passkey not recognised.' });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge: ceremony.challenge,
      expectedOrigin: config.origin,
      expectedRPID: config.rpID,
      credential: {
        id: passkey.id,
        publicKey: passkey.publicKey,
        counter: passkey.counter,
        transports: passkey.transports,
      },
      requireUserVerification: true,
    });
  } catch (err) {
    console.error('Authentication verification failed:', err);
    return res.status(400).json({ error: 'Could not verify the passkey.' });
  }

  if (!verification.verified) {
    return res.status(401).json({ error: 'Could not verify the passkey.' });
  }

  store.updatePasskey(passkey.id, {
    counter: verification.authenticationInfo.newCounter,
    lastUsedAt: Date.now(),
  });

  await logIn(req, passkey.userId);
  res.json({ verified: true });
});

Because the login was usernameless, we find the account by looking up the credential ID the browser sent, then compare the user handle the authenticator returns with that passkey's owner. The WebAuthn spec asks for that second check, and it costs one line. The library then verifies the signature with the stored public key and checks the challenge, origin, rpID, and counter. We never touch a password, and nothing secret is stored.

Session endpoints and startup

app.get('/api/me', (req, res) => {
  const user = req.session.userId ? store.findUserById(req.session.userId) : null;
  if (!user) return res.status(401).json({ error: 'Not signed in.' });

  const passkeys = store.listPasskeys(user.id).map((p) => ({
    id: p.id,
    backedUp: p.backedUp,
    createdAt: p.createdAt,
  }));
  res.json({ username: user.username, passkeys });
});

app.post('/api/logout', (req, res) => {
  req.session.destroy(() => {
    res.clearCookie('sid');
    res.json({ ok: true });
  });
});

// Express 5 forwards errors from async handlers here automatically.
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Something went wrong.' });
});

app.listen(config.port, () => {
  console.log(`Listening on http://localhost:${config.port}`);
});

That completes the backend. If you are on Express 4, wrap each async handler in try/catch and call next(err), since Express 4 does not catch rejected promises for you.

The frontend

The page has two states: signed out (a username field plus two buttons) and signed in (a welcome line and the list of passkeys). Create public/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Passkeys demo</title>
  </head>
  <body>
    <main>
      <h1>Passkeys demo</h1>

      <section id="signed-out">
        <label for="username">Username</label>
        <input id="username" autocomplete="username webauthn" />
        <button id="register">Create account with a passkey</button>
        <button id="login">Sign in with a passkey</button>
      </section>

      <section id="signed-in" hidden>
        <p>Signed in as <strong id="whoami"></strong></p>
        <ul id="passkeys"></ul>
        <button id="add-passkey">Add another passkey</button>
        <button id="logout">Sign out</button>
      </section>

      <p id="status" role="status"></p>
    </main>
    <script type="module" src="/app.js"></script>
  </body>
</html>

The attribute that matters is autocomplete="username webauthn". The webauthn token tells the browser it may offer the user's passkeys in this field's autofill menu. It must come last in the token list. SimpleWebAuthn throws an error if you request autofill and the page has no such input.

Now public/app.js:

import {
  WebAuthnError,
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
  browserSupportsWebAuthnAutofill,
} from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@14/+esm';

const $ = (id) => document.getElementById(id);

function setStatus(message, isError = false) {
  const el = $('status');
  el.textContent = message;
  el.style.color = isError ? 'crimson' : 'inherit';
}

async function api(path, body = {}) {
  const res = await fetch(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error ?? 'Request failed.');
  return data;
}

function friendlyError(err) {
  if (err.name === 'NotAllowedError') return 'Cancelled or timed out. Please try again.';
  if (err.name === 'InvalidStateError') return 'This device already has a passkey for this account.';
  return err.message;
}

// Updates the page and returns true if the user is signed in.
async function refresh() {
  const res = await fetch('/api/me');
  const signedIn = res.ok;
  $('signed-out').hidden = signedIn;
  $('signed-in').hidden = !signedIn;

  if (signedIn) {
    const me = await res.json();
    $('whoami').textContent = me.username;
    $('passkeys').replaceChildren(
      ...me.passkeys.map((p) => {
        const li = document.createElement('li');
        const kind = p.backedUp ? 'Synced' : 'Device-bound';
        li.textContent = `${kind} passkey, added ${new Date(p.createdAt).toLocaleDateString()}`;
        return li;
      })
    );
  }
  return signedIn;
}

async function register() {
  const optionsJSON = await api('/api/register/options', { username: $('username').value });
  const response = await startRegistration({ optionsJSON });
  await api('/api/register/verify', { response });
}

async function login({ autofill = false } = {}) {
  const optionsJSON = await api('/api/login/options');
  const response = await startAuthentication({ optionsJSON, useBrowserAutofill: autofill });
  await api('/api/login/verify', { response });
}

async function run(action) {
  try {
    setStatus('Waiting for your passkey...');
    await action();
    setStatus('');
    await refresh();
  } catch (err) {
    setStatus(friendlyError(err), true);
  }
}

// Passkey autofill (conditional UI): the browser offers passkeys in the
// username field's autofill menu, and the ceremony stays pending until
// the user picks one.
async function startAutofill() {
  if (!(await browserSupportsWebAuthnAutofill())) return;
  try {
    await login({ autofill: true });
    await refresh();
  } catch (err) {
    // Clicking a button starts another ceremony, which cancels this one.
    if (err instanceof WebAuthnError && err.code === 'ERROR_CEREMONY_ABORTED') return;
    if (err.name !== 'NotAllowedError') {
      setStatus(friendlyError(err), true);
    }
  }
}

$('register').onclick = () => run(register);
$('add-passkey').onclick = () => run(register);
$('login').onclick = () => run(() => login());
$('logout').onclick = async () => {
  await api('/api/logout');
  if (!(await refresh())) startAutofill();
};

async function init() {
  if (!browserSupportsWebAuthn()) {
    setStatus('This browser does not support passkeys.', true);
    return;
  }
  if (!(await refresh())) startAutofill();
}

init();

A few things to notice:

  • startRegistration and startAuthentication take the options JSON from your server and handle the base64url-to-ArrayBuffer conversions that the raw browser API demands. They return a JSON-friendly response you can send straight back.

  • Autofill and the Sign in button use the same login() function. Starting a new ceremony automatically cancels a pending one, which is why the autofill handler ignores the abort error code ERROR_CEREMONY_ABORTED.

  • The passkey list is built with textContent, never innerHTML, so nothing from the server can inject markup.

  • The CDN URL uses the @14 range for convenience. In production, pin an exact version and ideally bundle or self-host it.

Run it and test it

node server.js

Open http://localhost:3000. Use localhost, not 127.0.0.1: the rpID is localhost, and the browser compares it against the page's hostname.

  1. Type a username and click Create account with a passkey. Approve the prompt with your fingerprint, face, or PIN.

  2. Click Sign out.

  3. Click into the username field. Your passkey should appear in the autofill menu. Pick it to sign in, or click Sign in with a passkey instead.

  4. Click Add another passkey on the same device. You should get the already-registered message, which proves excludeCredentials is working.

No biometric hardware? In Chrome DevTools, open More tools > WebAuthn, enable the virtual authenticator environment, and add an authenticator with the CTAP2 protocol, the internal transport, and both resident keys and user verification switched on. It behaves like a real passkey and makes automated testing possible.

To test from a phone or a real domain you need HTTPS. Put a tunnel or staging domain in front of the app and set the environment variables so the rpID and origin match it:

RP_ID=your-test-domain.example ORIGIN=https://your-test-domain.example node server.js

One demo-only gotcha: the in-memory store is wiped whenever the server restarts, but the passkey stays on your device. The next sign-in then fails with the not-recognised message. That is expected, and it disappears once you use a real database.

Security decisions worth understanding

The code above is short, but several lines exist purely for security. These are the ones to keep when you adapt it.

  • Challenges are single-use, short-lived, and tied to the session. takeCeremony deletes the challenge before verification runs, so a captured response cannot be replayed.

  • Expected values come from your server. The challenge, origin, and rpID passed to the verify functions come from your session and config, never from the request body.

  • User verification is required on both sides. The options ask for it and the verify call enforces it. If those two settings disagree, you get confusing failures on some devices.

  • The session ID is rotated on login. logIn regenerates the session so an attacker cannot plant a session ID before the victim signs in.

  • No passkey is added without a session. A signed-out request can only create a brand-new account, never modify an existing one.

  • Counter handling is left to the library. Many synced passkeys always report a counter of zero. SimpleWebAuthn only rejects a response when the stored or the new counter is non-zero and the new one is not higher. Do not add your own strict rule on top, or you will lock out legitimate users.

  • Errors are generic. The client learns that verification failed, not which check failed. The detail goes to your server logs.

  • Only public material is stored. A leaked database exposes public keys and credential IDs, neither of which can sign in as anyone.

Before you ship

  • Use a real session store. The default in-memory store leaks memory and does not work across multiple processes. Use Redis or a database-backed store.

  • Use a real database with the schema above, and keep the UNIQUE constraint on usernames so the signup race is also guarded at the database level. Check your driver's types too: many return BIGINT as a string, so convert the counter to a number, and some return binary values in wrapper types that you must convert to a plain Uint8Array.

  • Run over HTTPS with trust proxy set correctly so secure cookies work behind your load balancer.

  • Rate-limit the options and verify endpoints, for example with express-rate-limit. The login options endpoint is public and creates a session each time it is called.

  • Plan account recovery. Encourage users to register at least two passkeys. If you offer email recovery, remember that your account security is then only as strong as the user's email, so treat that path as security-critical.

  • Let users manage passkeys. Add list, rename, and delete screens, and do not let someone delete their last credential unless another way in exists.

  • Handle more than one origin if needed. expectedOrigin accepts an array, but all origins must share a valid rpID.

  • Pin dependency versions and read release notes. Major versions of the library changed how credentials are shaped, so upgrades deserve a test run. Keep the library patched as well: version 13.3.2 fixed a security issue in how registration verification checked attestation certificate chains.

  • Test across browsers and devices: Chrome, Safari, and Firefox; iOS and Android; and passkeys stored in password managers.

Troubleshooting

  • An error about the relying party ID not matching the domain. Your rpID does not match the page's hostname. Check that you are on localhost rather than 127.0.0.1, and that production uses your real domain.

  • A server error about an unexpected origin. The ORIGIN value does not match the page exactly. The usual culprits are the port number and http versus https.

  • Passkey not recognised after a restart. The in-memory store was cleared while the passkey remained on the device.

  • Autofill never appears. Confirm the input has autocomplete="username webauthn", the page is in a secure context, and the user already has a discoverable passkey for this site.

  • Verification fails only on some devices. Look for a user-verification mismatch, or a security key that has no PIN set.

What's next

You now have a complete passkey login flow with the checks that matter. The natural next steps are porting it to Next.js, where serverless functions mean you can no longer hold challenges in process memory, and adding account recovery and a migration path for existing password users.

FAQ

Are passkeys more secure than passwords?

Yes, in the ways that matter most. A passkey cannot be guessed, reused across sites, or typed into a fake login page, because the browser only releases it to the real origin. Your server also stores only a public key, so a breach does not expose anything an attacker can sign in with.

What happens if a user loses their phone?

It depends on the passkey. Passkeys synced through a platform account or password manager survive on the user's other devices. Device-bound passkeys do not. That is why you should encourage users to register a second passkey and provide a recovery path.

Do I need HTTPS to use passkeys?

Yes, in production. WebAuthn only works in a secure context. The exception is localhost, which browsers treat as secure for development.

Can I keep passwords alongside passkeys?

Yes. Many sites add passkeys as an option first and keep passwords as a fallback during migration. Just remember that your account is only as strong as its weakest sign-in method, so plan to retire or restrict passwords over time.

Which library should I use?

SimpleWebAuthn handles the cryptographic verification and the browser-side encoding, which are the easiest parts to get wrong. Writing your own verification is possible but rarely worth the risk. If you would rather not run any of this yourself, an authentication provider can handle passkeys for you, at the cost of depending on that service.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Arpan Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Arpan Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.