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.
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.
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.
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'Source:https://dev.to/devstories_fca96eb46af114/passkey-account-recovery-designing-for-lost-devices-1h51Related posts
SV-WAM: An Efficient Surround-View World-Action Model for End-to-End Autonomous Driving
LOGIV: Graph-Verified Execution with Causal Local Repair for Long-Horizon Robot Tasks
Staff Augmentation Companies in Canada: How to Choose a Resource Augmentation Partner
