Blog
5 min read

Password Reset and Email Verification Flows Done Right

Password resets are one of the most attacked parts of any app. How to build reset and email-verification flows securely: token generation and hashing, expiry, account enumeration, host header poisoning, invalidating sessions, and the UX details that reduce support tickets.

"Forgot password?" is a backdoor into every account, by design. If the reset flow is weak, your password hashing, 2FA and rate limits don't matter — an attacker just resets the password. Email verification is its close cousin: it proves a user controls the address they signed up with. Both are built from the same pieces, and both have well-known pitfalls.

If you use an auth provider or a mature framework (Supabase Auth, Clerk, Auth0, Better Auth, Django, Rails, Laravel), these flows are built in — use them. This guide explains what "done right" means so you can check, and build it properly if you must.

The core building block: a single-use token

Both flows email the user a link containing a token. Requirements:

  1. Unpredictable. Generate at least 32 random bytes from a cryptographically secure source (crypto.randomBytes(32) in Node, secrets.token_urlsafe(32) in Python). Never use timestamps, user IDs, Math.random() or anything guessable.
  2. Stored hashed. Save a hash of the token (SHA-256 is fine for long random tokens), not the token itself. If your database leaks, the attacker can't use pending reset links.
  3. Short-lived. Password resets: 15–60 minutes. Email verification: a day or two is reasonable.
  4. Single-use. Mark it used (or delete it) the moment it's redeemed.
  5. Bound to one user and one purpose. A verification token must not work as a reset token.
import crypto from "node:crypto";

const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");

await db.query(
  `INSERT INTO password_resets (user_id, token_hash, expires_at)
   VALUES ($1, $2, now() + interval '30 minutes')`,
  [user.id, tokenHash]
);

const link = `${process.env.APP_URL}/reset-password?token=${token}`;

The password reset flow

Requesting a reset

  • Same response whether or not the account exists: "If an account exists for that email, we've sent a reset link." Otherwise attackers can use the form to discover who has an account (account enumeration). Keep the response time similar too.
  • Rate-limit by email and by IP, so nobody can flood an inbox or your email bill. (What is rate limiting?)
  • Invalidate older unused tokens for that user when issuing a new one.
  • Build the link from a configured APP_URL, never from the request's Host header. Otherwise an attacker can request a reset with a forged host, and the victim receives a link to the attacker's domain — host header poisoning — which leaks the token when clicked.

Completing the reset

  • Look up the token by its hash; check it's unused and unexpired.
  • Ask for the new password (with the same strength rules as signup), hash it properly (password hashing explained), save it.
  • Mark the token used.
  • Invalidate all existing sessions for that user. If the reset happened because the account was compromised, the attacker's session must die too. (Session vs JWT — this is where sessions are easier.)
  • Email the user that their password was changed, with a way to report it if it wasn't them.
  • Don't log them straight in if they have 2FA; a reset must never bypass two-factor authentication.

Protect the token in transit

  • Keep the token out of logs and analytics: reset pages shouldn't load third-party scripts that capture the full URL.
  • Set Referrer-Policy: no-referrer (or strict-origin) on the reset page so the token isn't leaked to other sites. (Security headers explained.)
  • Consider having the link land on a page that submits the token via POST, which also protects against email scanners "clicking" the link.

The email verification flow

Same token mechanics, different purpose: confirm the user owns the address.

  • Send the verification link at signup; allow resending (rate-limited).
  • Until verified, limit what the account can do — especially sending email to others, inviting team members, or anything costing you money.
  • When a user changes their email, verify the new address before switching, and notify the old address. Otherwise an attacker who briefly gets into an account can change the email and lock the owner out.
  • "Sign in with Google" accounts usually arrive verified; trust the provider's verified flag, not just the presence of an email.

UX details that cut support tickets

  • Tell users how long the link is valid and to check spam.
  • If a link has expired or was used, explain that and offer to send a new one in one click.
  • Make sure the email actually arrives quickly: proper SPF, DKIM and DMARC, and a transactional email provider. (Send email from your app without landing in spam.)
  • Make links work on mobile and when opened on a different device than the request.

A checklist

  • Tokens: 32+ random bytes, stored hashed, expire, single-use, purpose-bound
  • Same response for existing and non-existing emails
  • Rate limits on requests
  • Links built from a configured base URL, not the Host header
  • All sessions invalidated after a reset; confirmation email sent
  • Resets don't bypass 2FA
  • Email changes verify the new address and notify the old one
  • No tokens in logs, analytics or referrers

The summary

  • Reset and verification links are single-use, short-lived, random tokens — stored as hashes.
  • Don't reveal whether an account exists; rate-limit; build links from a fixed base URL.
  • After a reset: kill all sessions, notify the user, keep 2FA in force.
  • Use your auth provider's built-in flows when you can.

EasySpawn runs your backend with a fixed app URL on your own HTTPS domain and secrets kept server-side — and Claude Code can test these flows end to end, including the email that has to arrive. See how it works or join the waitlist.

Related: Magic Link Login · How to Add Login to an AI-Built App · OWASP Top 10 Explained · Authentication vs Authorization

Keep reading