Build a forgot-password flow that doesn't leak accounts
By the end of this recipe you'll have a four-stage password reset — email → check-your-inbox → set-new-password → success — with rate-limited resends, a strength meter, and the security-first behaviour that never tells an attacker whether an email is registered.
Overview
A four-stage flow: email → sent → reset → success. Same state-machine spine as the 2FA recipe, with a clock-driven resend and password rules.
Forgot-password is the second-most-trafficked auth surface after sign-in, and it's where most apps quietly leak. A naive "we couldn't find that email" message turns the form into an account-enumeration oracle — an attacker can probe a million addresses and walk away with a list of every customer you have. The Huchu pattern always says "If we found you, we sent a link" and behaves identically whether the email exists or not.
The flow is two pages on the user's side and one email in between. Stage 1 ('email') collects the address; stage 2 ('sent') is an interstitial with a resend timer; the user clicks the email link and lands on stage 3 ('reset') to set a new password; stage 4 ('success') confirms and forwards to sign-in. The same component handles both visits — query-string token decides whether we open at 'email' or jump to 'reset'.
Skip this recipe if your app uses passwordless / magic-link sign-in only — there is nothing to reset. Send the user back to sign-in and let them request a fresh link.
What you'll build
Three on-screen stages plus one email round-trip. The reset link is the bridge between stage 2 and stage 3.
Forgot your password?
Tell us the email on your account and we'll send a reset link.
Check your inbox
If tendai@mukamba.co matches an account, we sent a link. It expires in 15 minutes.
Resend in 0:42
Set a new password
It must be at least 10 characters.
You can see this live in
Portal demos that wire this recipe in context. Open one to walk the reset flow end to end.
Required pieces
Everything this recipe pulls from @corelithzw/react. Click any to see its reference page.
@corelithzw/react exports used here: Stack, Form, Field, Input, Button, Alert, Meter, AuthShell. The shell, error-linking and resend-timer conventions are copied verbatim from Sign in with 2FA — keep the same names so a user who's read one recipe can read the next at a glance.
Step-by-step build
Model the four stages and the security-first response
Use the same useReducer shape as the 2FA recipe — string-union stage, one error slot, dispatch actions for forward/back. The important rule lives in the reducer: EMAIL_SUBMITTED always goes to 'sent', regardless of what the API actually returned. We never branch on "user exists" in the UI, full stop.
EMAIL_SUBMITTED always advances. No "user not found" branch.
OPEN_RESET is dispatched from a useEffect that reads ?token=… on mount.
Switch to the Code tab to see the snippet.
Render the email screen — and rate-limit on the server
The form is a single field plus a primary button. The visual response is identical whether the address exists or not, so the only thing that ever surfaces to the user from the API is a rate-limit error ("Too many attempts. Try again in 5 minutes"). All other failures fall through to the 'sent' stage to preserve the no-leak contract.
Forgot your password?
Tell us the email on your account and we'll send a reset link.
Build the "check your inbox" interstitial with resend cooldown
This is the page that has to lie convincingly. Spell out exactly what will happen — link, 15-minute expiry, where it came from — without ever using the words "your account" (which presumes one). The resend cooldown is the same useEffect timer pattern as 2FA — disable the button until zero so a curious user can't pummel the SMS or email gateway.
Check your inbox
If tendai@mukamba.co matches an account, we sent a link. It expires in 15 minutes.
Render the new-password screen with a real strength meter
Two password fields, a four-segment meter, and a confirm field that's disabled until the first is valid. The strength is a tiny pure function — length, char classes, no common-passwords list inline (do that server-side). Show specific rules under the field, not "weak / strong" alone, so the user knows what to add.
Set a new password
At least 10 characters, one uppercase, one number.
Land on success and forward to sign-in
Once the API confirms, sign the user out of any other sessions (server-side) and bounce them to /signin. The success screen is just an acknowledgement — a tick, two lines, an auto-forward. Don't auto-sign-in: a freshly-reset password is the strongest moment to require the user to actually type it.
Password updated
We signed you out of every device. Sign in again with your new password.
Final composition
The whole ForgotPassword component. Reducer, all four stages, resend cooldown, strength meter, token pickup from the URL.
Forgot your password?
Tell us the email on your account and we'll send a reset link.
Variations
Three forks. Each is a small diff from the final composition — the four-stage spine stays intact.
Admin-assisted reset
The user can't reset themselves (e.g. school-portal parents). Replace the email screen with a "contact your admin" panel and skip stages 1–3 entirely.
// Skip the user-driven flow
type Stage = 'contact';
return (
<Stack>
<h1>Ask your admin</h1>
<p>Ruvimbo Mhlanga can reset you.</p>
</Stack>
);
OTP instead of email link
SMS the 6-digit code, not a link. Stage 2 becomes an OTP screen, stage 3 stays the same. Useful where deliverability is shaky.
// Stage 2: OTP, not interstitial
{s.stage === 'sent' && (
<InputOtp length={6}
onComplete={(c) => verify(c)}
autoComplete="one-time-code" />
)}
Re-prompt for 2FA on reset
Workspace requires 2FA. Add a sub-stage between 'reset' and 'success' that re-verifies the second factor before the new password takes effect.
type Stage = 'email' | 'sent'
| 'reset' | 'reset-otp' | 'success';
case 'RESET_OK':
return { ...s, stage: 'reset-otp' };
case 'OTP_OK':
return { ...s, stage: 'success' };
Accessibility
What this recipe takes care of for you. Each item is something a screen-reader or keyboard user will notice.
- The "sent" interstitial is a live region.
role="status"+aria-live="polite"means the screen-reader reads the confirmation without the user having to navigate to it. - The strength meter has a text label.
Meterrendersaria-valuetextlike "Strong" so SR users hear the rating, not just a numeric value. - Inputs declare new-password.
autoComplete="new-password"on both fields lets the password manager generate-and-save, and turns off keyboard autosuggest that would otherwise leak the value. - Errors are linked, not just rendered. Every
Alertcarries anid; the form carries a matchingaria-describedby, so the field name and the error are read together. - Resend disables itself. Real
disabledwhile the countdown runs — keyboard skips it, screen readers say "Resend in 0:42, dimmed", not a clickable target. - The confirm field stays disabled until the first is valid. Saves a screen-reader user from typing a password twice into a form that won't submit anyway.
- The whole flow is keyboard-only. Tab moves through every interactive control in reading order; Enter submits each form; Esc on stage 2 returns to stage 1.