A SaaS team I worked with rolled out passkeys last spring and celebrated a big drop in password-reset tickets. Three months later, a review of their support logs showed something uncomfortable. Their "lost my passkey" flow sent a magic link to the account email, the link stayed valid for seven days, it could be reused, and changing the email address did not require any re-authentication. Sign-in had become very strong. Recovery was still the weakest version of their old password reset. Attackers rarely break the front door when there is an unlocked side door right next to it.
I wrote this with help from AI tools and checked every snippet against current OWASP Forgot Password and Authentication guidance, NIST SP 800-63B, Laravel 11 and Node 22. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the free developer tools at PixelValut. Everything here is about hardening recovery in an app you own.
📚 Series Navigation — Passkeys & Session Hardening for Web Apps
- Part 1: Rolling Out Passkeys Without Locking Users Out
- Part 2: Session Lifecycle Hardening: Rotation, Timeouts & Server-Side Revocation
- Part 3: CSRF in 2026: SameSite, Fetch Metadata & Origin Checks
- Part 4 (This Post): Account Recovery Hardening: The Side Door Next to Your Passkey
- Part 5: Protecting Login Endpoints: Credential-Stuffing Signals & Adaptive Rate Limits
- Part 6: Step-Up Authentication for Sensitive Actions
Related earlier posts: Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls · OAuth 2.1, OIDC ID Tokens & PKCE Forensics
How do you secure account recovery when users sign in with passkeys?
Direct answer: Treat recovery as a sign-in method with the same strength as your weakest factor. Use single-use, high-entropy tokens stored only as hashes, expire them in 15–30 minutes, give the same response whether or not the account exists, rate-limit per account and IP, notify every existing channel, revoke sessions after recovery, and add a cooldown before sensitive changes.
Why recovery is the real security boundary
Your account is only as strong as the easiest path into it. With passwords, that path was usually phishing. With passkeys, phishing gets much harder, so the easiest path moves to recovery: email links, SMS codes, support desk overrides and "change email" forms. If any of those accepts less proof than a passkey, that is now your effective security level.
The goal is not to make recovery impossible. Users do lose phones. The goal is to make recovery slower, louder and more auditable than normal sign-in, so a real owner gets back in and an impostor gets noticed.
Recovery methods compared
| Recovery method | Strength | Main risk | User friction | When I use it |
|---|---|---|---|---|
| Second registered passkey (another device) | High | User never registers one | Low | Always prompt after first passkey |
| Single-use recovery codes (printed/stored) | High | Lost or stored in plain notes | Medium | Offer at enrolment, require confirmation |
| Email magic link | Medium | Mailbox compromise, long-lived links | Low | With short expiry + notifications + cooldown |
| SMS code | Low–medium | SIM swap, interception | Low | Avoid as sole factor; secondary signal only |
| Support desk / ID check | Varies | Social engineering of agents | High | Last resort, with scripted verification |
The single most effective change is getting users to register a second passkey or save recovery codes at enrolment. Every account that has one rarely needs the weaker paths at all.
Building a safe recovery token
Most recovery bugs I find are in token handling. The token should be random, single-use, short-lived and never stored in plain text.
Node (Express) example:
import crypto from 'node:crypto'; const TOKEN_TTL_MS = 20 * 60 * 1000; // 20 minutes export async function issueRecoveryToken(db, userId) { const raw = crypto.randomBytes(32).toString('base64url'); // 256 bits const hash = crypto.createHash('sha256').update(raw).digest('hex'); // Invalidate any earlier unused tokens for this user await db.query('UPDATE recovery_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [userId]); await db.query( 'INSERT INTO recovery_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [userId, hash, new Date(Date.now() + TOKEN_TTL_MS)] ); return raw; // only ever sent in the email, never logged } export async function consumeRecoveryToken(db, raw) { const hash = crypto.createHash('sha256').update(raw).digest('hex'); const { rows } = await db.query( `UPDATE recovery_tokens SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() RETURNING user_id`, [hash] ); return rows[0]?.user_id ?? null; // atomic: second use returns null } The UPDATE ... RETURNING makes consumption atomic, so two requests racing with the same link cannot both succeed. Storing only the SHA-256 hash means a database leak does not hand out working links. You can sanity-check your hashing output with a hash generator while debugging, using test values only.
Laravel: the built-in password broker already hashes tokens and expires them. Tighten the defaults in config/auth.php:
'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 20, // minutes, default is 60 'throttle' => 120, // seconds between requests per user ], ], For a passkey "add a new device" recovery flow, reuse the same broker pattern rather than inventing a second token system.
Same response, every time
The recovery form should never tell a stranger whether an account exists:
app.post('/recover', recoverLimiter, async (req, res) => { const user = await findUserByEmail(req.body.email); if (user) { const token = await issueRecoveryToken(db, user.id); await sendRecoveryEmail(user, token); } // Identical body, status and similar timing either way res.status(202).json({ message: 'If that account exists, we sent a link.' }); }); Send the email from a queue job so response time does not reveal whether a message was actually sent.
After recovery: limit the blast radius
Getting back in should not immediately unlock everything. After a successful recovery I apply four rules:
- Revoke all other sessions and refresh tokens. Part 2 of this series covers server-side revocation.
- Notify every channel on file, including the old email if it was changed, with a clear "this wasn't me" link.
- Start a cooldown (for example 24–72 hours) before changing email, adding payout details, exporting data or removing other passkeys.
- Log the event with timestamp, IP range and user agent so support can review it later.
When checking cooldown windows and token expiry in logs, a Unix timestamp converter saves a lot of mental arithmetic.
The support desk is part of your auth system
Many real account takeovers go through a helpful support agent. Give agents a script: verify through existing channels, never change the email and the recovery method in the same ticket, and require a second agent for overrides on high-value accounts. Record every manual override as an auth event, not just a ticket note.
Diagnostic checklist
- [ ] Every user is prompted to add a second passkey or save recovery codes at enrolment.
- [ ] Recovery tokens are at least 128 bits of randomness, single-use and stored as hashes.
- [ ] Tokens expire in 30 minutes or less and earlier tokens are invalidated on reissue.
- [ ] The recovery endpoint returns the same response whether or not the account exists.
- [ ] Recovery is rate-limited per account and per IP.
- [ ] All sessions are revoked after recovery and every channel is notified.
- [ ] Sensitive changes have a cooldown after recovery.
- [ ] Support overrides are scripted, logged and reviewed.
FAQ
If users have passkeys, why keep email recovery at all?
Because people lose devices and not everyone syncs passkeys. Removing email recovery entirely tends to push users to support, which is often weaker. Keep it, but make it short-lived, noisy and followed by a cooldown.
Are SMS codes acceptable for recovery?
As a secondary signal, sometimes. As the only factor, I avoid them because of SIM-swap risk. NIST classes SMS as a restricted authenticator for this reason.
How long should a recovery link last?
I use 15–30 minutes. Long enough to switch to an email app, short enough that an old email in a compromised mailbox is useless.
Should recovery codes be hashed too?
Yes. Store them like passwords, using a slow hash such as bcrypt or Argon2, and mark each one used after a single redemption.
Does a cooldown annoy legitimate users?
Slightly, and that is the trade-off. Explain it clearly in the UI ("For your safety, email changes are paused for 48 hours after recovery"). Most users accept it when they understand why.
What should I monitor?
Spikes in recovery requests, recoveries followed quickly by email or payout changes, and many recovery requests from one IP range. Those patterns usually show an attack before customers report it.
Next in this series, Part 5 covers protecting login endpoints with credential-stuffing signals and adaptive rate limits.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal



