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