Quickstart

A working passwordless sign-up and sign-in, end to end, in about five minutes.

A complete Express app with passwordless sign-up and sign-in. Copy it, run it, then replace the store.

Install

npm install passkify express express-session

Create the server

// server.js
import express from 'express';
import session from 'express-session';
import { PasskeyServer, MemoryStore } from 'passkify';
 
const app = express();
app.use(express.json());
app.use(session({ secret: 'dev-only', resave: false, saveUninitialized: false }));
 
const passkeys = new PasskeyServer({
  rpName: 'Acme',
  origin: 'http://localhost:3000',   // scheme + host + port, exactly as served
  store: new MemoryStore(),          // development only
});

Three required options. rpName is the site name the operating system shows in the passkey prompt, origin is where your page is served from, and store is where credentials live. Everything else has a sensible default; see configuration.

Mount the routes

app.use(
  passkeys.express({
    // Identifies the signed-in visitor. Enables adding a second passkey and
    // unlocks the credential-management routes.
    getSessionUserId: (req) => req.session.userId ?? null,
 
    // Start a session as soon as either ceremony succeeds.
    onRegister: (req, res, { user }) => { req.session.userId = user.id; },
    onLogin:    (req, res, { user }) => { req.session.userId = user.id; },
  }),
);
 
app.listen(3000);

This mounts four routes under /passkey. The middleware calls next() for anything else, so it is safe at the top of the stack. See HTTP adapters for the full route table.

Call it from the browser

<input id="username" autocomplete="username webauthn" placeholder="username">
<button id="signup">Create account</button>
<button id="signin">Sign in</button>
 
<script type="module">
  import { register, login, signInWithAutofill } from 'passkify/client';
 
  signup.onclick = async () => {
    await register({ username: username.value });
    location.href = '/';
  };
 
  signin.onclick = async () => {
    await login();          // no username needed
    location.href = '/';
  };
 
  // Offer passkeys inside the browser's own autofill dropdown.
  signInWithAutofill().then((r) => { if (r) location.href = '/'; }).catch(() => {});
</script>

Run it

node server.js
# open http://localhost:3000

Your browser offers Touch ID, Windows Hello, or your phone. There is no password field anywhere in this app.

What just happened

register() asked your server for options

POST /passkey/register/start returned a random challenge, your rpID, and a freshly minted user handle. No account was created yet.

The browser created a key pair

After Touch ID or Windows Hello confirmed a human was present, the authenticator generated a key pair, kept the private half, and signed a statement about the ceremony.

Your server verified it and stored the public half

POST /passkey/register/finish ran fifteen checks, and only then created the account and stored the credential. An abandoned prompt leaves nothing behind.

login() did the same in reverse

The browser showed a picker of passkeys for your domain, signed a fresh challenge with the private key, and your server verified that signature against the stored public key.

There is no shared secret in that flow, so there is nothing in your database worth stealing and nothing for a phishing page to capture.

Before you ship

Replace MemoryStorerequired
Set a real session secretrequired
Rate limit the start routesrecommended
Decide on account recoveryrecommended

Next