Adding to an existing app

Staged rollout alongside passwords, without opening an account-takeover hole.

You almost certainly should not replace passwords on day one. Here is the staged path, and the one mistake that turns a passkey rollout into an account-takeover vulnerability.

Stage 1: offer passkeys as an addition

Let signed-in users register one from account settings. Your session already identifies them, so nothing about your existing auth changes.

Add a passkey handle to your users table

ALTER TABLE users ADD COLUMN passkey_handle TEXT UNIQUE;
CREATE INDEX users_passkey_handle ON users (passkey_handle);

Why a separate column rather than reusing your primary key

Map your users onto PasskeyUser

export const store = {
  ...credentialAndChallengeMethods,
 
  async getUserById(handle) {
    const row = await db.user.findByPasskeyHandle(handle);
    return row && { id: row.passkeyHandle, username: row.email, displayName: row.name };
  },
 
  async getUserByUsername(email) {
    const row = await db.user.findByEmail(email);
    return row?.passkeyHandle
      ? { id: row.passkeyHandle, username: row.email, displayName: row.name }
      : null;
  },
 
  async createUser() {
    // Nobody signs up passkey-first yet.
    throw new Error('passkey-first signup is not enabled');
  },
};

Add the registration route, behind your existing auth

app.post('/settings/passkeys/start', requireAuth, async (req, res) => {
  let handle = req.user.passkeyHandle;
 
  // Mint one on first use.
  if (!handle) {
    handle = crypto.randomUUID();
    await db.user.setPasskeyHandle(req.user.id, handle);
  }
 
  const { options } = await passkeys.startRegistration({ userId: handle });
  res.json(options);
});
 
app.post('/settings/passkeys/finish', requireAuth, async (req, res) => {
  await passkeys.finishRegistration(req.body);
  res.json({ ok: true });
});

The account comes from req.user, never from the body.

Add the button

import { register, configure } from 'passkify/client';
 
configure({ baseUrl: '/settings/passkeys' });
await register();   // no username: adds to the signed-in account

At this point nobody can sign in with a passkey yet. That is fine. You are building enrolment first so that stage 2 has users to serve.

Stage 2: offer passkeys as a login option

Add the login routes

app.post('/auth/passkey/start', async (req, res) => {
  const { options } = await passkeys.startAuthentication();   // usernameless
  res.json(options);
});
 
app.post('/auth/passkey/finish', async (req, res) => {
  const { user } = await passkeys.finishAuthentication(req.body);
 
  // `user.id` is the passkey handle. Resolve it to your own user.
  const account = await db.user.findByPasskeyHandle(user.id);
 
  // Reuse whatever login(email, password) already calls.
  await establishSession(req, account);
  res.json({ ok: true });
});

Reuse your existing session code, do not write new session code

Put it next to the password form, not instead of it

<input name="email" autocomplete="username webauthn">
<input name="password" type="password" autocomplete="current-password">
<button type="submit">Sign in</button>
 
<button type="button" id="passkey">Sign in with a passkey</button>
import { login, signInWithAutofill } from 'passkify/client';
 
passkey.onclick = async () => { await login(); location.href = '/'; };
 
// The autocomplete token above also lets passkeys appear in the browser's
// own dropdown, above the saved passwords.
signInWithAutofill().then((r) => { if (r) location.href = '/'; }).catch(() => {});

Stage 3: consider dropping passwords

Only once enough of your users have enrolled, and only with a recovery path that does not depend on a password.

Measure enrolment firstbefore anything
Keep a recovery channelrequired
Do not delete password hashes immediatelyreversible

See account recovery for the full discussion.

Migrating from another WebAuthn library

Credentials are portable if you have the right fields. You need, per credential:

passkify fieldWhat it is
idbase64url credential ID
publicKeybase64url of the raw COSE key bytes
algorithmCOSE identifier, -7 for ES256
counterlast known signature counter
userIdmust equal the user handle already on the device

rpID must also be identical to what the credentials were created under. Changing it orphans every one of them.