Tutorial

Passkey Account Recovery: Designing for Lost Devices

Design and build passkey account recovery in Express: nudges, recovery codes, emailed links, and cleanup, following NIST SP 800-63B-4.

·
23 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Passkey Account Recovery: Designing for Lost Devices

Passkeys make signing in safer, but they move the hard problem to the day a user loses their phone. Sign-in is the easy half. Recovery decides whether your system is actually secure, because an attacker does not need to beat your passkeys if they can talk their way through the recovery flow instead.

This guide designs and builds that recovery path for the Express app from the passkeys guide. It follows NIST SP 800-63B-4, the current US standard for authenticators and account recovery, and says plainly where we go beyond it or fall short of it. By the end you will have:

  • A readiness check that finds at-risk accounts and tells your UI what to nudge

  • Passkey management endpoints with re-authentication and a last-passkey guard

  • Single-use recovery codes, stored as salted hashes and replaced after use

  • A recovery flow that needs an emailed link plus a recovery code, with a delayed fallback for users who saved nothing

  • Full cleanup after recovery: old passkeys revoked, other sessions signed out, and the owner notified

This guide assumes the code from the passkeys guide (config.js, store.js, and server.js with startCeremony, takeCeremony, and logIn). It also assumes your signup already collects and verifies an email address. An unverified address is a hole in recovery, so add that first if you do not have it.

How people actually lose access

What happens when a device disappears depends on what kind of passkey the user had:

Situation

Synced passkey

Device-bound passkey

One of several devices is lost

Still works on the others

Works if a second passkey is registered

The only device is lost

Can be restored on a new device through the platform or password manager account

Locked out

The platform account itself is lost

Locked out

Not affected

The device is stolen

The thief still needs the biometric or PIN

Same

The user's ecosystem matters too. A passkey saved in one platform's keychain does not automatically follow them to a phone from another ecosystem, which is why a cross-platform password manager or a second passkey keeps coming up as the answer.

You cannot see which situation a user is in, but at registration you stored two clues. deviceType is multiDevice when the authenticator says the passkey can be backed up and synced, and singleDevice when it cannot. backedUp says whether it currently is. Those two fields are enough to decide who most urgently needs a nudge.

The design: prevent, recover, escalate

Think in three layers, from cheapest and safest to most expensive and riskiest.

  1. Prevent lockouts. Get users to register a second passkey, save recovery codes, and confirm an email address while they still have a working device. NIST tells services to encourage at least two separate means of authentication so that recovery is rarely needed.

  2. Recover by themselves. Recovery needs two independent proofs, never one. For an account that can sign in at NIST's AAL2 level (a passkey with a biometric or PIN qualifies), NIST accepts two recovery codes obtained by different methods, one recovery code plus a single-factor authenticator already bound to the account, or repeated identity proofing. We use an emailed link plus a saved recovery code, which is two different methods.

  3. Escalate. Users with neither a second passkey nor a code need a slower path. We build a delayed email fallback, and note where high-value accounts should add identity verification instead.

Each recovery method has a different failure mode, so it helps to see them side by side:

Method

Best for

Weak spot

Second passkey

Everyone; instant, no email involved

Users have to set it up before they need it

Recovery codes

Users who save them somewhere safe

Never saved, lost, or left in a screenshot

Emailed link plus a recovery code

Strong self-service recovery

Needs both pieces

Email only, after a delay

Users who saved nothing

Only as strong as the mailbox, and an attacker can wait

Support or identity verification

High-value accounts

Slow, costly, and a favourite target for social engineering

Skip security questions and SMS codes as your only fallback. NIST treats SMS and voice delivery as a restricted method, and its password rules prohibit security questions, because both are easy to social-engineer.

These rules run through every piece of code below:

  • Recovery is an account-takeover path. An account is only as strong as its easiest way back in.

  • Always notify. Every recovery, every new passkey, and every new set of recovery codes sends an email to the existing address, with a way to say it was not them. NIST requires notification for recovery and for newly bound authenticators.

  • Treat a lost authenticator as stolen. After recovery we revoke every old passkey and sign out every old session, as NIST advises for compromised authenticators.

  • Recovery codes are credentials. At least 64 random bits, stored only as salted hashes, throttled, single use, and replaced after use.

  • Emailed links are single use and short lived. NIST allows at most 24 hours for email. We use 30 minutes on the fast path.

  • Never reveal whether an account exists. Every recovery request gets the same answer.

NIST is a US federal guideline, not a law that binds your app. Use it as a well-reasoned checklist, and write down any place you choose to depart from it.

Data model changes

Users need a verified email and a sessionVersion counter. Bumping the counter signs out every existing session at once, which we need after a recovery. Passkeys get a friendly name so users can tell their devices apart. Recovery codes and recovery requests get their own tables.

ALTER TABLE users
  ADD COLUMN email           TEXT UNIQUE,            -- store lowercase
  ADD COLUMN email_verified  BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0;

ALTER TABLE passkeys ADD COLUMN name TEXT NOT NULL DEFAULT 'Passkey';

CREATE TABLE recovery_codes (
  id      BIGSERIAL PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  salt    BYTEA NOT NULL,
  hash    BYTEA NOT NULL,
  used_at TIMESTAMPTZ
);

CREATE TABLE recovery_requests (
  id         TEXT PRIMARY KEY,   -- random UUID, also the cancel handle
  user_id    TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  method     TEXT NOT NULL,      -- 'email+code' or 'email+delay'
  status     TEXT NOT NULL,      -- pending, released, used, cancelled
  token_hash TEXT UNIQUE,        -- SHA-256 of the emailed token
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  release_at TIMESTAMPTZ NOT NULL,
  expires_at TIMESTAMPTZ
);

For the in-memory demo store, add the following to store.js and replace createUser so new accounts get the new fields:

// store.js - replace createUser, then add the rest
export function createUser(user) {
  if (usersByName.has(user.username)) return false;
  const record = { email: null, emailVerified: false, sessionVersion: 0, ...user };
  usersByName.set(record.username, record);
  usersById.set(record.id, record);
  return true;
}

export function updateUser(id, changes) {
  const user = usersById.get(id);
  if (user) Object.assign(user, changes);
  return user;
}

export const deletePasskey = (id) => passkeys.delete(id);

const recoveryCodes = new Map(); // userId -> [{ salt, hash, usedAt }]
export const listRecoveryCodes = (userId) => recoveryCodes.get(userId) ?? [];
export const replaceRecoveryCodes = (userId, records) => recoveryCodes.set(userId, records);

// Check and set in one synchronous step, so a code can never be spent twice.
export function consumeRecoveryCode(userId, index) {
  const record = recoveryCodes.get(userId)?.[index];
  if (!record || record.usedAt) return false;
  record.usedAt = Date.now();
  return true;
}

const recoveryRequests = new Map(); // id -> request
export const saveRecoveryRequest = (request) => recoveryRequests.set(request.id, request);
export const findRecoveryRequest = (id) => recoveryRequests.get(id);
export const listRecoveryRequests = () => [...recoveryRequests.values()];
export const findRecoveryRequestByTokenHash = (hash) =>
  [...recoveryRequests.values()].find((r) => r.tokenHash === hash);

Add these values to the config object in config.js:

recoveryLinkTtlMs: 30 * 60 * 1000, // emailed link on the fast path
  recoveryDelayedLinkTtlMs: 24 * 60 * 60 * 1000, // NIST's maximum for email
  recoveryDelayMs: Number(process.env.RECOVERY_DELAY_MS ?? 24 * 60 * 60 * 1000),
  recoverySessionTtlMs: 15 * 60 * 1000,
  reauthMaxAgeMs: 5 * 60 * 1000,

recoveryDelayMs is the waiting period for users who have no recovery code. Set it low while testing, for example RECOVERY_DELAY_MS=60000.

Session helpers

Replace logIn in server.js and add the helpers below. logIn keeps its signature, so the existing calls in the registration and login handlers keep working. It now also records the user's sessionVersion and the time of the passkey check.

function logIn(req, userId) {
  const user = store.findUserById(userId);
  return new Promise((resolve, reject) => {
    req.session.regenerate((err) => {
      if (err) return reject(err);
      req.session.userId = user.id;
      req.session.sessionVersion = user.sessionVersion; // lets us sign out every session at once
      req.session.authenticatedAt = Date.now(); // for step-up checks
      req.session.save((err2) => (err2 ? reject(err2) : resolve()));
    });
  });
}

// The signed-in user, unless their session predates a recovery or sign-out-everywhere.
function currentUser(req) {
  const user = req.session.userId ? store.findUserById(req.session.userId) : null;
  return user && req.session.sessionVersion === user.sessionVersion ? user : null;
}

// A limited session created by a verified recovery link. It can only add a passkey.
function recoveryUser(req) {
  const recovery = req.session.recovery;
  if (!recovery || recovery.expiresAt < Date.now()) return null;
  return store.findUserById(recovery.userId) ?? null;
}

function requireAuth(req, res, next) {
  req.user = currentUser(req);
  if (!req.user) return res.status(401).json({ error: 'Not signed in.' });
  next();
}

// Sensitive actions need a passkey check from the last few minutes.
function requireRecentAuth(req, res, next) {
  if (Date.now() - (req.session.authenticatedAt ?? 0) > config.reauthMaxAgeMs) {
    return res
      .status(403)
      .json({ error: 'Please confirm with your passkey.', code: 'reauth_required' });
  }
  next();
}

The recovery session is deliberately weak. It is not a login: currentUser ignores it, and the only thing the server lets it do is start one passkey registration.

Email helper

Create mail.js. This demo version prints emails to the console so you can follow the whole flow without a mail provider. Replace sendMail with your real provider before shipping.

// mail.js
export async function sendMail(to, subject, text) {
  console.log(`\n--- email to ${to} ---\n${subject}\n\n${text}\n`);
}

// Tell the account owner about a security event, if they have an email on file.
export async function notify(user, subject, text) {
  if (user?.email) await sendMail(user.email, subject, text);
}

Add import { sendMail, notify } from './mail.js'; to the top of server.js.

Prevent lockouts: the readiness check

The readiness check turns the stored deviceType and backedUp fields into a nudge. A user with one synced passkey can survive losing a device, so the prompt is gentle. A user with a single device-bound passkey is one accident away from a lockout, so it is urgent.

function recoveryReadiness(user) {
  const passkeys = store.listPasskeys(user.id);
  const syncedPasskey = passkeys.some((p) => p.deviceType === 'multiDevice' && p.backedUp);
  const unusedCodes = store.listRecoveryCodes(user.id).filter((c) => !c.usedAt).length;

  const issues = [];
  if (passkeys.length < 2) {
    issues.push({ code: 'add_second_passkey', urgent: !syncedPasskey });
  }
  if (unusedCodes === 0) issues.push({ code: 'create_recovery_codes', urgent: true });
  if (!user.emailVerified) issues.push({ code: 'verify_email', urgent: true });

  return { status: issues.length === 0 ? 'ready' : 'at_risk', unusedCodes, issues };
}

app.get('/api/me', requireAuth, (req, res) => {
  const passkeys = store.listPasskeys(req.user.id).map((p) => ({
    id: p.id,
    name: p.name ?? 'Passkey',
    deviceType: p.deviceType,
    backedUp: p.backedUp,
    createdAt: p.createdAt,
    lastUsedAt: p.lastUsedAt,
  }));

  res.json({
    username: req.user.username,
    rpID: config.rpID,
    webauthnUserID: Buffer.from(req.user.id, 'utf8').toString('base64url'),
    passkeys,
    readiness: recoveryReadiness(req.user),
  });
});

This replaces the /api/me handler from the passkeys guide. Besides the readiness result it returns two values the browser needs later for the Signal API: the rpID and the WebAuthn user handle (the user's ID, base64url-encoded exactly as it was when we registered the passkey). Your frontend can map each issue code to a message and a button. Show the urgent ones right after sign-in, while the user still has a working device.

Manage passkeys safely

Users need to see their passkeys, name them, and remove old ones. Removal is the dangerous part, so it gets two guards: a passkey check from the last five minutes, and a refusal to delete the last passkey.

const cleanName = (value) => String(value ?? '').trim().slice(0, 40);

app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
  const passkey = store.findPasskey(req.params.id);
  const name = cleanName(req.body?.name);
  if (!passkey || passkey.userId !== req.user.id) {
    return res.status(404).json({ error: 'Passkey not found.' });
  }
  if (!name) return res.status(400).json({ error: 'Give the passkey a name.' });

  store.updatePasskey(passkey.id, { name });
  res.json({ ok: true });
});

app.delete('/api/passkeys/:id', requireAuth, requireRecentAuth, async (req, res) => {
  const passkey = store.findPasskey(req.params.id);
  if (!passkey || passkey.userId !== req.user.id) {
    return res.status(404).json({ error: 'Passkey not found.' });
  }
  if (store.listPasskeys(req.user.id).length <= 1) {
    return res.status(409).json({ error: 'Add another passkey before removing your last one.' });
  }

  store.deletePasskey(passkey.id);
  await notify(
    req.user,
    'A passkey was removed from your account',
    `The passkey named ${passkey.name ?? 'Passkey'} was removed. If this was not you, secure your account now.`
  );
  res.json({ ok: true });
});

Both handlers check passkey.userId !== req.user.id and answer with the same not-found error, so one user can never probe or modify another user's credentials by guessing IDs. The step-up check matters because a hijacked session should not be able to quietly strip the real owner's passkeys. When the server answers with reauth_required, the frontend runs the normal passkey sign-in and retries; the frontend section shows how.

Recovery codes

Recovery codes are the second proof on the fast path. Each code carries 80 bits of randomness (16 symbols from a 32-symbol alphabet that drops look-alikes such as 0, O, 1, and I), comfortably above NIST's 64-bit minimum. We store only a salted scrypt hash of each code, so a database leak does not expose usable codes, and we show the plaintext to the user exactly once.

// recoveryCodes.js
import crypto from 'node:crypto';
import { promisify } from 'node:util';
import * as store from './store.js';

const scrypt = promisify(crypto.scrypt);
const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // 32 symbols, no 0/O/1/I

const normalize = (input) => String(input).toUpperCase().replace(/[^A-Z0-9]/g, '');

function generateCode() {
  // 16 symbols x 5 bits = 80 bits. 256 is a multiple of 32, so b & 31 has no bias.
  const raw = [...crypto.randomBytes(16)].map((b) => ALPHABET[b & 31]).join('');
  return raw.match(/.{4}/g).join('-');
}

async function hashCode(code) {
  const salt = crypto.randomBytes(16);
  const hash = await scrypt(normalize(code), salt, 32);
  return { salt, hash, usedAt: null };
}

async function matches(code, record) {
  const hash = await scrypt(normalize(code), record.salt, 32);
  return crypto.timingSafeEqual(hash, record.hash);
}

// Replaces the user's whole set. Returns the plaintext codes: show them once, never store them.
export async function issueRecoveryCodes(userId, count = 10) {
  const codes = Array.from({ length: count }, generateCode);
  store.replaceRecoveryCodes(userId, await Promise.all(codes.map(hashCode)));
  return codes;
}

// Returns true if the code was valid and has now been spent.
export async function spendRecoveryCode(userId, input) {
  if (normalize(input).length !== 16) return false; // skip the slow hash for junk

  const records = store.listRecoveryCodes(userId);
  for (let i = 0; i < records.length; i++) {
    if (records[i].usedAt) continue;
    if (await matches(input, records[i])) return store.consumeRecoveryCode(userId, i);
  }
  return false;
}

Two details matter. spendRecoveryCode runs scrypt against each unused code, which is deliberately slow, so rate-limit every endpoint that calls it. And the final consumeRecoveryCode call happens synchronously after the check, so two simultaneous requests cannot spend the same code twice.

Next, an endpoint that lets a signed-in user create a set. A new set replaces the old one entirely, and NIST treats issuing a replacement code as a recovery event, so it triggers a notification.

app.post('/api/recovery-codes', requireAuth, requireRecentAuth, async (req, res) => {
  const codes = await issueRecoveryCodes(req.user.id);
  await notify(
    req.user,
    'New recovery codes were created',
    'A new set of recovery codes was created. Older codes no longer work. If this was not you, secure your account now.'
  );
  res.json({ codes }); // the only time the plaintext is ever shown
});

Add import { issueRecoveryCodes, spendRecoveryCode } from './recoveryCodes.js'; to the top of server.js. Tell users to keep the codes in a password manager or print them. Never email them, and keep them out of logs and error reports.

Let a recovery session add a passkey

In the passkeys guide, POST /api/register/options decided who it was serving from the session. Recovery adds a third case: a signed-out visitor holding a verified recovery session. Three changes are needed in the registration routes.

In POST /api/register/options, replace the first two lines of the handler, the excludeCredentials option, and the startCeremony call:

// 1. Replace the lines that set `user` and `isNew`:
  const signedIn = currentUser(req);
  const recovering = signedIn ? null : recoveryUser(req);
  let user = signedIn ?? recovering;
  let isNew = false;

// 2. Replace the excludeCredentials option:
    // During recovery every old passkey is about to be revoked, so exclude nothing.
    excludeCredentials: recovering
      ? []
      : store.listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),

// 3. Replace the startCeremony call:
  startCeremony(req, {
    type: 'registration',
    challenge: options.challenge,
    user,
    isNew,
    recovering: Boolean(recovering),
  });

In POST /api/register/verify, check that the recovery session is still alive right after the ceremony is taken, and replace the last two lines (logIn and res.json):

// Right after: const ceremony = takeCeremony(req, 'registration'); and its null check
  if (ceremony.recovering && !recoveryUser(req)) {
    return res.status(403).json({ error: 'Your recovery session expired. Please start again.' });
  }

// Replace the final logIn and res.json lines with:
  let recoveryCodes = null;
  if (ceremony.recovering) {
    recoveryCodes = await completeRecovery(ceremony.user, credential.id);
  } else if (!ceremony.isNew) {
    await notify(
      store.findUserById(ceremony.user.id),
      'A passkey was added to your account',
      'A new passkey was added. If this was not you, remove it and secure your account now.'
    );
  }

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

The recovering flag lives in the server-side ceremony, set only when the options request arrived under a real recovery session, so a client cannot claim it. The final logIn regenerates the session, which also discards the recovery session. Adding a passkey to an existing account now sends a notification, which NIST requires for any newly bound authenticator.

There are two entry points behind one endpoint. A user with a recovery code sends their username and a code, and gets a link right away. A user without a code sends only a username, and the link is held back for a waiting period while the real owner is warned.

import crypto from 'node:crypto'; // already imported in server.js

const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const humanize = (ms) =>
  ms >= 3600000 ? `${Math.round(ms / 3600000)} hours` : `${Math.round(ms / 60000)} minutes`;

async function releaseRecoveryLink(request, ttlMs) {
  const user = store.findUserById(request.userId);
  const token = crypto.randomBytes(32).toString('base64url');

  request.tokenHash = sha256(token); // only the hash is stored
  request.expiresAt = Date.now() + ttlMs;
  request.status = 'released';

  // The token travels in the URL fragment, so it never reaches server logs or Referer headers.
  await sendMail(
    user.email,
    'Finish recovering your account',
    `Open this link to add a new passkey. It works once and expires in ${humanize(ttlMs)}:\n\n` +
      `${config.origin}/recover.html#token=${token}\n\n` +
      'If you did not ask for this, you can ignore this email.'
  );
}

async function processRecoveryStart(username, code) {
  const user = username ? store.findUserByUsername(username) : null;
  if (!user || !user.emailVerified) return;

  // A wrong code stops here. The caller already received the same generic answer.
  if (code && !(await spendRecoveryCode(user.id, code))) return;

  const now = Date.now();
  const active = store
    .listRecoveryRequests()
    .filter(
      (r) =>
        r.userId === user.id &&
        (r.status === 'pending' || (r.status === 'released' && r.expiresAt > now))
    );

  // Repeat requests must not reset a waiting timer. A code-backed request replaces any waiting one.
  if (!code && active.length > 0) return;
  for (const r of active) r.status = 'cancelled';

  const request = {
    id: crypto.randomUUID(),
    userId: user.id,
    method: code ? 'email+code' : 'email+delay',
    status: 'pending',
    createdAt: now,
    releaseAt: code ? now : now + config.recoveryDelayMs,
    tokenHash: null,
    expiresAt: null,
  };
  store.saveRecoveryRequest(request);

  if (code) {
    await releaseRecoveryLink(request, config.recoveryLinkTtlMs);
    await notify(
      user,
      'Account recovery started',
      'A recovery code was used to start recovering your account. If this was not you, secure your account now and contact support.'
    );
  } else {
    await sendMail(
      user.email,
      'Account recovery requested',
      `Someone asked to recover your account without a recovery code. If it was you, do nothing: we will email a link in about ${humanize(config.recoveryDelayMs)}.\n\n` +
        `If it was not you, cancel the request here:\n${config.origin}/recover.html#cancel=${request.id}`
    );
  }
}

app.post('/api/recovery/start', (req, res) => {
  const username = normalizeUsername(req.body?.username);
  const code = typeof req.body?.code === 'string' && req.body.code.trim() ? req.body.code : null;

  // Answer first and work afterwards, so neither the response nor its timing
  // reveals whether the account exists or the code was right.
  res.status(202).json({ ok: true });
  processRecoveryStart(username, code).catch((err) => console.error('Recovery start failed:', err));
});

Three behaviours are worth calling out:

  • The answer never changes. Unknown username, unverified email, wrong code, and success all return the same 202, so the form cannot be used to find out who has an account.

  • A wrong code ends quietly. It does not silently drop the user into the slow path, and it sends no email.

  • A waiting request cannot be reset. Without this rule, an attacker could keep a victim's timer from ever finishing, or restart it forever.

The waiting path needs something to release links when the delay ends, plus an endpoint for the real owner to cancel. In production use a proper scheduler or job queue. A timer is enough for the demo:

// Release links whose waiting period is over.
setInterval(async () => {
  for (const request of store.listRecoveryRequests()) {
    if (request.status !== 'pending' || request.releaseAt > Date.now()) continue;
    try {
      await releaseRecoveryLink(request, config.recoveryDelayedLinkTtlMs);
    } catch (err) {
      console.error('Could not release a recovery link:', err);
    }
  }
}, 60 * 1000).unref();

app.post('/api/recovery/cancel', (req, res) => {
  // The request ID is a random UUID that was only ever emailed to the account owner.
  const request = store.findRecoveryRequest(req.body?.id);
  if (request && (request.status === 'pending' || request.status === 'released')) {
    request.status = 'cancelled';
  }
  res.json({ ok: true }); // same answer whether or not anything was cancelled
});

The cancel link is the reason the delay is worth having. It gives the real owner a window to shut down an attacker's request, but only if they read the email, so also notify any second address you hold. Be clear about the trade-off: this path relies on a single recovery method, so it falls short of NIST's AAL2 rule of two. Offer it only where that risk is acceptable, and remove it for high-value accounts.

app.post('/api/recovery/verify', async (req, res) => {
  const token = req.body?.token;
  const request =
    typeof token === 'string' ? store.findRecoveryRequestByTokenHash(sha256(token)) : null;

  if (!request || request.status !== 'released' || request.expiresAt < Date.now()) {
    return res.status(400).json({ error: 'This recovery link is invalid or has expired.' });
  }
  request.status = 'used'; // single use: flipped before any await

  await new Promise((resolve, reject) => {
    req.session.regenerate((err) => {
      if (err) return reject(err);
      req.session.recovery = {
        userId: request.userId,
        expiresAt: Date.now() + config.recoverySessionTtlMs,
      };
      req.session.save((err2) => (err2 ? reject(err2) : resolve()));
    });
  });
  res.json({ ok: true });
});

The result is the limited recovery session from earlier, not a login. The recovery page reads the token from the URL fragment and only sends it after the user clicks Continue. That keeps it out of logs, and it means mail scanners that open links cannot use it up.

Recovery, step 3: add a new passkey and clean up

The user now registers a passkey through the routes we modified. When that registration verifies, completeRecovery does the part most tutorials forget:

async function completeRecovery(userRef, newPasskeyId) {
  const user = store.findUserById(userRef.id);

  // 1. A lost authenticator is assumed stolen: revoke every other passkey.
  for (const passkey of store.listPasskeys(user.id)) {
    if (passkey.id !== newPasskeyId) store.deletePasskey(passkey.id);
  }

  // 2. Sign out every earlier session (logIn re-reads this value).
  store.updateUser(user.id, { sessionVersion: user.sessionVersion + 1 });

  // 3. Close any other open recovery request, such as an attacker's waiting one.
  for (const r of store.listRecoveryRequests()) {
    if (r.userId === user.id && (r.status === 'pending' || r.status === 'released')) {
      r.status = 'cancelled';
    }
  }

  // 4. Replace the recovery codes: the old set may be on the lost device or in the wrong hands.
  const codes = await issueRecoveryCodes(user.id);

  // 5. Tell the owner.
  await notify(
    user,
    'Your account was recovered',
    'A new passkey was added and all earlier passkeys and sessions were removed. If this was not you, contact support immediately.'
  );

  return codes;
}

Each step answers a specific attack:

  • Revoking old passkeys means a thief holding the lost phone cannot sign in after the owner has recovered.

  • Bumping sessionVersion kills sessions the thief may already have open. Without it, recovery would add a new door and leave the old one unlocked.

  • Closing other requests stops an attacker's waiting request from firing after the real owner has already recovered.

  • New codes are returned to the page once, which is why the registration response carries recoveryCodes.

  • The notification is the owner's alarm if someone else ran the whole flow.

Keep the user's devices honest with the Signal API

Revoking a passkey on your server does not remove it from the user's password manager. The next time they tap the stale passkey, sign-in fails with a confusing error. The WebAuthn Signal API lets your site tell credential managers which passkeys are still valid, so they can hide the rest.

It is best effort. As of mid-2026, Chrome and Edge 132 or newer, recent Chrome on Android, and Safari 26 support it. Firefox does not, and a credential manager is free to ignore the signal. Treat it as polish on top of the server-side revocation above, never as a replacement for it.

We send two signals. After sign-in and after any passkey change, allAcceptedCredentials lists every passkey the server still accepts. After a failed sign-in with an unknown passkey, unknownCredential names the stale one. The second needs one small server change in POST /api/login/verify, right after the findPasskey line:

if (!passkey) {
    // Lets the browser ask the user's credential manager to hide this stale passkey.
    return res
      .status(400)
      .json({ error: 'Passkey not recognised.', unknownCredential: response?.id });
  }

On the client, add sendSignal to the existing import from @simplewebauthn/browser, then add the reconcile helper and update login():

// Call this from refresh() after it loads /api/me
async function reconcilePasskeys(me) {
  try {
    await sendSignal({
      signalName: 'allAcceptedCredentials',
      rpID: me.rpID,
      userID: me.webauthnUserID,
      allAcceptedCredentialIDs: me.passkeys.map((p) => p.id),
    });
  } catch {
    // Unsupported browser, or the signal was ignored. Nothing to do.
  }
}

async function login({ autofill = false } = {}) {
  const optionsJSON = await api('/api/login/options');
  const response = await startAuthentication({ optionsJSON, useBrowserAutofill: autofill });
  try {
    await api('/api/login/verify', { response });
  } catch (err) {
    if (typeof err.data?.unknownCredential === 'string') {
      sendSignal({
        signalName: 'unknownCredential',
        rpID: optionsJSON.rpId,
        credentialID: err.data.unknownCredential,
      }).catch(() => {});
    }
    throw err;
  }
}

The userID must be exactly the value that was sent as the user handle when the passkey was created. That is why /api/me returns webauthnUserID encoded the same way as our registration options.

Frontend changes

The recovery page

Create public/recover.html. It has five states, and the script shows one at a time depending on whether the URL carries a recovery token, a cancel handle, or neither.

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

      <section id="start">
        <label for="username">Username</label>
        <input id="username" autocomplete="username" />
        <label for="code">Recovery code (optional, skips the waiting period)</label>
        <input id="code" autocomplete="off" spellcheck="false" />
        <button id="send">Send recovery email</button>
      </section>

      <section id="confirm" hidden>
        <p>Continue to add a new passkey to your account.</p>
        <button id="continue">Continue</button>
      </section>

      <section id="finish" hidden>
        <p>Link verified. Create a new passkey on this device to finish.</p>
        <button id="create">Create new passkey</button>
      </section>

      <section id="done" hidden>
        <p>
          Your account is recovered and your old passkeys were removed. Save these new recovery
          codes now. They are shown only once.
        </p>
        <pre id="codes"></pre>
      </section>

      <section id="cancel" hidden>
        <p>Cancel the recovery request for your account?</p>
        <button id="cancel-btn">Yes, cancel it</button>
      </section>

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

Then public/recover.js:

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

const $ = (id) => document.getElementById(id);
const SECTIONS = ['start', 'confirm', 'finish', 'done', 'cancel'];
const show = (name) => SECTIONS.forEach((id) => ($(id).hidden = id !== name));

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

async function post(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;
}

async function run(action) {
  try {
    setStatus('');
    await action();
  } catch (err) {
    setStatus(err.message, true);
  }
}

// Read the secret from the URL fragment, then remove it from the address bar and history.
const params = new URLSearchParams(location.hash.slice(1));
history.replaceState(null, '', location.pathname);

$('send').onclick = () =>
  run(async () => {
    await post('/api/recovery/start', { username: $('username').value, code: $('code').value });
    setStatus('If that account can be recovered, an email is on its way.');
  });

$('continue').onclick = () =>
  run(async () => {
    await post('/api/recovery/verify', { token: params.get('token') });
    show('finish');
  });

$('create').onclick = () =>
  run(async () => {
    const optionsJSON = await post('/api/register/options');
    const response = await startRegistration({ optionsJSON });
    const { recoveryCodes } = await post('/api/register/verify', { response });
    $('codes').textContent = recoveryCodes.join('\n');
    show('done');
  });

$('cancel-btn').onclick = () =>
  run(async () => {
    await post('/api/recovery/cancel', { id: params.get('cancel') });
    setStatus('Recovery cancelled. Sign in and review your passkeys.');
  });

show(params.has('token') ? 'confirm' : params.has('cancel') ? 'cancel' : 'start');

Both the token and the cancel handle wait for a button click before anything is sent. A mail scanner that opens the link, even one that runs JavaScript, cannot consume a token or cancel a request on its own.

Changes to the main app

In public/app.js, extend api() so callers can read the server's error code and payload, and add a helper that retries an action once after a fresh passkey check:

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

// Retry once after a fresh passkey check when the server asks for one.
async function withReauth(action) {
  try {
    return await action();
  } catch (err) {
    if (err.code !== 'reauth_required') throw err;
    await login();
    return action();
  }
}

const NUDGES = {
  add_second_passkey: 'Add a second passkey so losing one device cannot lock you out.',
  create_recovery_codes: 'Create recovery codes and keep them somewhere safe.',
  verify_email: 'Verify your email address so you can recover your account.',
};

// Needs an empty <ul id="nudges"></ul> in the signed-in section of index.html
function renderReadiness(readiness) {
  $('nudges').replaceChildren(
    ...readiness.issues.map(({ code, urgent }) => {
      const li = document.createElement('li');
      li.textContent = (urgent ? 'Important: ' : '') + NUDGES[code];
      return li;
    })
  );
}

Call renderReadiness(me.readiness) and reconcilePasskeys(me) from refresh() once /api/me has loaded. Then wire the buttons you need. Removing a passkey is withReauth(() => api('/api/passkeys/' + id, {}, 'DELETE')), renaming is api('/api/passkeys/' + id, { name }, 'PATCH'), and creating recovery codes is withReauth(() => api('/api/recovery-codes')). Show the returned codes once, with copy and download buttons, and tell the user plainly what they are for.

Test the lost-device scenarios

The demo has no email-verification step. For testing only, change the defaults in createUser so every account gets a verified address, and emails print to the server console:

// store.js, demo only
const record = {
  email: `${user.username}@example.test`,
  emailVerified: true,
  sessionVersion: 0,
  ...user,
};

The fast path (recovery code plus emailed link):

  1. In Chrome DevTools, open More tools > WebAuthn, enable a virtual authenticator, and create an account on http://localhost:3000.

  2. You just signed in, so the step-up check passes. In the page console, create recovery codes and copy one: await (await fetch('/api/recovery-codes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json()

  3. Simulate losing the device: remove that virtual authenticator, then open a private window and add a new one.

  4. Open /recover.html, enter the username and the code, and send. The server console prints the email with the link. Open it, click Continue, then Create new passkey.

  5. Check the results. The page shows ten new codes. The original window's next request returns 401, because its session predates the recovery. The old code no longer works. The console shows the notification emails.

The waiting path: restart the server with RECOVERY_DELAY_MS=60000. Request recovery with a username and no code. The console prints a cancel link. Cancel once and confirm that no recovery link ever arrives. Request again and wait one to two minutes: the recovery link prints.

Things that must fail:

  • Opening a recovery link a second time, or after it expires

  • Spending the same recovery code twice

  • A wrong code, or an unknown username: both return the same response and print nothing

  • Deleting your only passkey: refused

  • Deleting a passkey more than five minutes after signing in: you are asked to confirm with your passkey first

Before you ship

  • Use a real mail provider with SPF, DKIM, and DMARC set up, and let users add a second notification address. A notification nobody reads protects nobody.

  • Rate-limit the recovery endpoints per IP and per account, and cap failed code attempts per account well below NIST's upper bound of 100. A starting point with express-rate-limit is below.

  • Keep tokens and codes out of logs, analytics, and error reports. Treat them as credentials.

  • Store recovery requests in your database and release links from a real scheduler or queue, not a timer in the web process. Make the release step safe to run twice.

  • Write an audit trail for every security event, and give support staff the same state machine as users. A support button that skips the flow is the easiest account takeover you can build.

  • Treat email changes as recovery events. Require a fresh passkey check, notify the old address, and consider a waiting period, because whoever controls the address controls the delayed path.

  • Raise the bar for high-value accounts. Remove the email-only path and require identity verification or hardware security keys. Note that NIST does not allow synced passkeys at its highest assurance level, AAL3.

  • Test the whole loss matrix: one device lost, all devices lost, platform account lost, ecosystem switch, and email lost too.

import rateLimit from 'express-rate-limit';

const recoveryLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api/recovery', recoveryLimiter);
app.use('/api/recovery-codes', recoveryLimiter);

FAQ

Because then your account security is exactly the security of the mailbox, and a mailbox is usually protected by something weaker than a passkey. If you accept that trade-off for low-risk accounts, add a waiting period and notifications, as the delayed path here does. Otherwise require a second proof such as a recovery code.

How many passkeys should I ask users to register?

Two, on different devices or in different ecosystems, plus a set of saved recovery codes. One synced passkey is a reasonable minimum, but it still depends on a single platform account.

Do synced passkeys solve recovery on their own?

Partly. They survive the loss of a single device while the platform or password manager account is intact. They do not help if that account is lost, if the user moves to a different ecosystem, or if the user registered a device-bound passkey. Use the stored deviceType and backedUp fields to see which case you are dealing with.

What if the user lost their email access too?

Recovery codes are the answer, and for users with neither you need a human process that verifies identity before changing anything. Do not let that process bypass the notifications and revocation steps above.

Should I keep passwords as a backup sign-in method?

It is tempting, but a password fallback makes your account only as strong as the password, and it brings back phishing. If you do keep passwords during migration, plan to retire them, and never use the password path to skip the recovery rules.

Comments(0)

Join the discussion securely with your ZyVOP account.

Continue on ZyVOP to Comment

No comments yet. Be the first to comment!

DevStories

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

Subscribe to DevStories's Newsletter

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