EncryptCodecencryptcodec
Blog/Security
SecurityMarch 30, 2026 · 7 min read

Secure Password Reset Tokens — Expiry, Storage, and What Most Implementations Get Wrong

You shipped a password reset flow in an afternoon, tested the happy path, and moved on. Six months later, a security researcher files a report: your tokens never expire, the same link works after use, and the token is 8 hex characters — bruteforceable in under an hour against your public endpoint. This is not a hypothetical. It's one of the most common auth vulnerabilities in production apps.

Use a cryptographically random token of at least 32 bytes, store its hash in the database, enforce a 15–30 minute expiry, and invalidate it immediately on use. Everything else in this article is the reasoning and the implementation.

Why Token Entropy Actually Matters

A UUID v4 looks random, but it's only 122 bits of actual entropy — and some UUID libraries have had weak PRNG implementations. More critically, UUIDs are a known format, which slightly aids enumeration. Use your platform's cryptographic random bytes generator and encode to hex or base64url.

A 32-byte (256-bit) random token gives you an astronomically large search space. Even at 10,000 requests per second against your reset endpoint, an attacker won't get anywhere before your rate limiter or your token's expiry kills the attempt.

import crypto from 'crypto';

// Generate a 32-byte cryptographically secure token
const rawToken = crypto.randomBytes(32).toString('hex'); // 64-char hex string

// Hash before storing in DB
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');

// Store tokenHash + expiry in DB, send rawToken in the email link
const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes

The Hash-Before-Store Rule

This is the step most tutorials skip. If you store rawToken directly in your database and your DB is exposed — via SQL injection, a backup misconfiguration, or a breach — every outstanding reset token becomes a live account takeover vector. An attacker can just iterate through the tokens and hit your /reset-password?token=... endpoint.

RESET TOKEN STORAGE
1
Generate Token
crypto.randomBytes(32).toString('hex')
2
Store Raw Token in DB
INSERT INTO resets (token, expires_at) VALUES ($rawToken, ...)
3
Send Link in Email
https://app.com/reset?token=<rawToken>
4
Redeem: Lookup Raw Token
SELECT user_id FROM resets WHERE token = $incomingToken
💀
⚠ DB Exposed
Attacker reads raw tokens → directly resets any account
Raw tokens in DB → any backup or SQL injection leaks live reset links

Click any step to expand details

Store SHA-256(rawToken). Send rawToken in the email. On redemption, hash the incoming token and compare against the stored hash. The raw token exists only in the email, which is a separate trust boundary.

Expiry Windows and What Happens When You Get Them Wrong

15–30 minutes is the right window. Here's why the edges are wrong:

Too short (under 5 minutes) causes legitimate failures — email delivery can be slow, users juggle tabs, corporate mail gateways add delays. You'll get support tickets, not better security.

Too long (24 hours, or worse, no expiry at all) means a phishing email or a temporarily compromised inbox gives an attacker a full day to take action. Tokens left in email threads, forwarded messages, or browser history become durable attack vectors.

Set the expiry when you create the token, not when you send the email. There's a non-obvious race condition here: if your email queue is slow and you start the clock on send, a queued email could arrive with a token that's already near expiry.

One-Time Use Is Not Optional

Delete or invalidate the token the moment it's redeemed — before you actually update the password if you can, or atomically within the same transaction. If you leave the token valid after use, a replay attack lets someone who intercepts the URL (proxy logs, browser history, shoulder surfing) reset the password again.

The correct DB schema has a used_at timestamp or you simply delete the row on redemption. Checking used_at IS NULL is fine; just make sure the redemption endpoint does it in a transaction so a race condition can't allow two simultaneous uses.

-- Atomic redemption: mark used and return the record in one query
UPDATE password_reset_tokens
SET used_at = NOW()
WHERE token_hash = $1
  AND used_at IS NULL
  AND expires_at > NOW()
RETURNING user_id;

If this query returns zero rows, reject the request — token is expired, already used, or doesn't exist. Don't tell the user which one. A generic "link is invalid or expired" message prevents user enumeration.

Invalidate Old Tokens on New Request

When a user requests a second reset before using the first, invalidate the previous token. You don't want three live reset links floating around in someone's inbox. This is also useful when a user suspects their email is compromised — requesting a new reset should kill the old one.

One active token per user at a time. The DB constraint is simple: before inserting a new token, DELETE FROM password_reset_tokens WHERE user_id = $1.

Rate Limiting the Reset Endpoint

Your reset request endpoint is a user enumeration and abuse surface. Rate limit by IP (5 requests per 15 minutes is reasonable) and return the same response whether the email exists or not: "If that address is in our system, you'll receive an email." This prevents attackers from using your reset flow to confirm whether an email is registered.

The actual token redemption endpoint deserves its own rate limit — maybe 10 attempts per token per hour. Even with proper entropy, you don't want unconstrained attempts against a token before it expires.

Don't Put Tokens in GET Parameters if You Can Help It

In practice most reset flows use GET links with the token as a query parameter — it's unavoidable if you want the user to click a link in their email. But be aware: that URL ends up in server access logs, browser history, and potentially in Referer headers if your reset page loads third-party resources (analytics, fonts, etc.).

Mitigate this by having the reset page immediately exchange the token for a short-lived server-side session and redirect to a clean URL. The token only lives in the URL for one request, not throughout the whole reset form interaction.


Pick one thing to fix right now: open your current reset token generation code and check whether you're hashing before storage. If you're storing the raw token, that's your highest-priority change — swap in a SHA-256 hash today. The EncryptCodec random secret generator can help you verify the entropy of tokens you're generating during local testing.

Share this post

Generate a cryptographically secure reset token

Free, browser-based — no signup required.

Frequently Asked Questions

Related posts