Building a Passkey Account Recovery Flow
Recovery is where passkey security is most often quietly undone: an account protected by phishing-resistant credentials is handed back to whoever can receive an SMS. A sound recovery flow preserves the guarantees — it re-grounds the account through a cryptographically strong factor and forces re-enrollment of a new passkey, rather than granting a session over a weak channel. This page is the design reference for a passkey-only account’s recovery flow, within the credential revocation and account recovery cluster that is its parent.
Factor Strength Reference
| Recovery factor | Strength | Use |
|---|---|---|
| Second pre-registered passkey | Strongest — no downgrade | Primary recommendation |
| Hardware backup security key | Strong | Enterprise / high-assurance |
| One-time recovery codes (offline) | Medium | Consumer fallback, stored by user |
| Email magic link → device push | Medium (email-dependent) | Broad fallback |
| SMS OTP | Weak — SIM-swap risk | Last resort, token-scoped only |
The rule from NIST SP 800-63B and FIDO Alliance guidance: recovery must not silently drop below the assurance level the account normally enforces. If it must, scope the weak factor to issuing a re-enrollment token, not a session.
Root Cause Analysis (of insecure recovery)
1. Weak channel grants a full session. Emailing or texting a link that logs the user straight in makes the account only as strong as that channel.
2. No backup enrolled. If the flow never required a second passkey or recovery codes, the only options left are weak by construction.
3. Unscoped post-recovery session. A full-privilege session after recovery lets an attacker skip re-enrollment and persist.
4. Helpdesk social engineering. Manual recovery bypasses every cryptographic control if support can reset an account on a phone call.
Step-by-Step Resolution
Step 1 — Enforce a backup at enrollment
Require a second passkey or issue offline recovery codes when the user creates their first passkey, so recovery always has a strong option.
if (account.credentials.length === 1 && !account.recoveryCodesIssued) {
await promptSecondFactorEnrollment(account); // second passkey or recovery codes
}
Step 2 — Issue a single-use, hashed recovery token
import { randomBytes, createHash } from 'crypto';
function issueRecoveryToken() {
const raw = randomBytes(32); // 256-bit
return {
raw: raw.toString('base64url'), // delivered out-of-band
tokenHash: createHash('sha256').update(raw).digest(), // only the hash is stored
expiresAt: new Date(Date.now() + 15 * 60_000), // 15-minute TTL
};
}
Step 3 — Deliver out-of-band, prefer device push
Prefer a push to a verified backup device over SMS/email. If email is unavoidable, send a confirmation link that triggers the push, not the raw token.
Step 4 — Redeem into a scoped re-enrollment session only
const ok = await redeemRecoveryToken(userId, rawToken); // atomically sets used=true, checks TTL
if (!ok) throw Object.assign(new Error('Invalid or expired token'), { status: 403 });
const session = issueScopedSession(userId, { scope: 're-enroll', ttlSeconds: 300 });
Step 5 — Force new passkey enrollment, then revoke the scope
Grant full access only after the user registers a fresh passkey via designing secure registration endpoints; then revoke the scoped session immediately.
Verification and Testing
// A recovery token is single-use and expires
expect(await redeemRecoveryToken(u, t)).toBe(true);
expect(await redeemRecoveryToken(u, t)).toBe(false); // reuse blocked
Assert the post-recovery session can only reach the registration endpoint (any other route returns 403) and expires after 5 minutes. Simulate a SIM-swap by delivering the token to a changed number and confirm your policy blocks direct session grant. Add a test that dual-approval is required for any admin-initiated recovery.
Pitfalls
1. Full session from a weak channel. Scope weak factors to a re-enrollment token only.
2. No backup enrolled. Mandate a second passkey or recovery codes up front — mirror the client-side password-to-passkey fallback patterns.
3. Unscoped recovery session. Restrict to re-enrollment and expire quickly.
Related
- Credential Revocation and Account Recovery — the parent lifecycle and recovery token internals
- Revoking a Lost Passkey and Forcing Re-Authentication — the revocation step that precedes recovery
- Designing Secure Registration Endpoints — the re-enrollment endpoint the scoped session targets