A complete Express app with passwordless sign-up and sign-in. Copy it, run it, then replace the store.
Prefer to read finished code, or just try it?
Every file of this app, plus the Next.js App Router version, is reproduced in full on the examples page. A working version is running at /demo: it uses the published package against your own authenticator, so you can see the whole flow before writing anything.
Install
npm install passkify express express-sessionCreate 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:3000Your browser offers Touch ID, Windows Hello, or your phone. There is no password field anywhere in this app.
Why localhost works without HTTPS
WebAuthn requires a secure context, and browsers classify localhost as one.
Every other host needs real TLS. To test on a phone, use a tunnel; see
troubleshooting.
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 MemoryStorerequiredIt loses everything on restart, and across multiple workers a challenge issued by one process is invisible to the others, so roughly every other login fails. See storage.
Set a real session secretrequiredFrom the environment, and set cookie.secure = true behind TLS.
Rate limit the start routesrecommended/passkey/login/start issues a challenge to anyone who asks, and each one
costs a store write.
Decide on account recoveryrecommendedPrompt for a second passkey at signup. See account recovery.
Next
- How passkeys work for the concepts under all this.
- Framework guides for Next.js, Hono, SvelteKit and the rest.
- Storage to swap out
MemoryStore.