Building a Secure Password Reset Flow
· Cosyslabs
Password reset is one of the most common attack vectors in web applications. Weak implementations use short-lived tokens with poor entropy, store reset tokens in plaintext, or allow tokens to be reused after the password is changed. This walkthrough covers the security requirements and implementation of a production-grade password reset flow.
Security Requirements
A secure password reset flow must satisfy:
- High-entropy tokens: reset tokens must have at least 128 bits of randomness
- Short expiry: tokens expire after 15–60 minutes
- Single use: tokens are invalidated immediately after use
- Hashed storage: reset tokens stored in the database must be hashed (they are bearer credentials)
- Account enumeration prevention: the response must not reveal whether an email exists
- Rate limiting: password reset requests must be rate limited per email
- Timing attack prevention: token comparison must be constant-time
The Flow
1. User submits email → POST /auth/forgot-password
2. Server generates token, hashes it, stores in DB with expiry
3. Server sends email with raw token in URL
4. User clicks link → GET /reset-password?token=...
5. User submits new password → POST /auth/reset-password
6. Server hashes the submitted token, looks up matching hash in DB
7. If found and not expired: hash new password, update user, invalidate token
8. Redirect to login
Generating the Reset Token
import { randomBytes, timingSafeEqual, createHash } from "crypto";
function generateResetToken() {
// 32 random bytes = 256 bits of entropy
const rawToken = randomBytes(32);
// URL-safe Base64 encoding for the link
const tokenForUrl = rawToken.toString("base64url");
// SHA-256 hash for storage (token in DB is hashed, not plaintext)
const tokenHash = createHash("sha256").update(rawToken).digest("hex");
return { tokenForUrl, tokenHash };
}
Why hash the stored token? If an attacker gains read access to the database (SQL injection, backup theft), they cannot use the stored hash to reset passwords. The raw token is only in the email link.
Note: use SHA-256 (not bcrypt) for token storage — tokens are already high-entropy and don't need the computational expense of bcrypt. bcrypt is for low-entropy user passwords.
Handling the Request
import rateLimit from "express-rate-limit";
// Rate limit: max 5 requests per hour per IP
const resetRateLimit = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: "Too many reset requests. Please try again in an hour.",
standardHeaders: true,
legacyHeaders: false,
});
app.post("/auth/forgot-password", resetRateLimit, async (req, res) => {
const { email } = req.body;
// ALWAYS respond with success, even if email not found
// This prevents account enumeration
const successResponse = {
message: "If an account with that email exists, a reset link has been sent."
};
const user = await db.users.findByEmail(email);
if (!user) {
// Do not reveal that the email was not found
// Add a small delay to match the time taken when user exists
await new Promise(resolve => setTimeout(resolve, 200));
return res.json(successResponse);
}
// Invalidate any existing reset tokens for this user
await db.passwordResets.deleteByUserId(user.id);
// Generate new token
const { tokenForUrl, tokenHash } = generateResetToken();
// Store hashed token with expiry
const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
await db.passwordResets.create({
userId: user.id,
tokenHash,
expiresAt,
usedAt: null,
});
// Send email with raw token in URL
const resetUrl = `https://yourapp.com/reset-password?token=${tokenForUrl}`;
await email.send({
to: user.email,
subject: "Password Reset Request",
text: `Reset your password: ${resetUrl}\n\nThis link expires in 30 minutes.`,
});
return res.json(successResponse);
});
Processing the Reset
import bcrypt from "bcrypt";
import { timingSafeEqual, createHash } from "crypto";
app.post("/auth/reset-password", async (req, res) => {
const { token, newPassword, confirmPassword } = req.body;
// Validate inputs
if (!token || !newPassword || newPassword !== confirmPassword) {
return res.status(400).json({ error: "Invalid request" });
}
// Password strength check
if (newPassword.length < 12) {
return res.status(400).json({ error: "Password must be at least 12 characters" });
}
// Hash the submitted token to find it in the database
let rawTokenBuffer;
try {
rawTokenBuffer = Buffer.from(token, "base64url");
} catch {
return res.status(400).json({ error: "Invalid token format" });
}
const submittedHash = createHash("sha256").update(rawTokenBuffer).digest("hex");
// Look up the token record
const resetRecord = await db.passwordResets.findByTokenHash(submittedHash);
// All error cases return the same error to prevent information leakage
const invalidTokenError = { error: "Reset link is invalid or has expired" };
if (!resetRecord) {
return res.status(400).json(invalidTokenError);
}
if (resetRecord.usedAt) {
// Token already used — possible replay attack
// Consider alerting security team
return res.status(400).json(invalidTokenError);
}
if (new Date() > resetRecord.expiresAt) {
return res.status(400).json(invalidTokenError);
}
// Mark token as used BEFORE updating the password
// (prevents race conditions where two simultaneous requests both succeed)
const marked = await db.passwordResets.markUsed(resetRecord.id);
if (!marked) {
// Another request already used this token
return res.status(400).json(invalidTokenError);
}
// Hash the new password with bcrypt
const BCRYPT_ROUNDS = 12;
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
// Update the user's password
await db.users.updatePassword(resetRecord.userId, passwordHash);
// Invalidate all active sessions for this user
await db.sessions.deleteByUserId(resetRecord.userId);
// Optionally notify the user that their password was changed
const user = await db.users.findById(resetRecord.userId);
await email.send({
to: user.email,
subject: "Your password was changed",
text: "Your password was successfully changed. If you did not do this, contact support immediately.",
});
return res.json({ message: "Password changed successfully. Please log in." });
});
The Database Schema
CREATE TABLE password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex, never the raw token
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for lookup by token hash
CREATE INDEX idx_password_resets_token_hash ON password_resets(token_hash);
-- Index for cleanup job
CREATE INDEX idx_password_resets_expires_at ON password_resets(expires_at);
-- Clean up expired tokens nightly
-- DELETE FROM password_resets WHERE expires_at < NOW() - INTERVAL '1 day';
Preventing Timing Attacks
Without constant-time comparison, an attacker could measure response times to determine how many characters of a token matched:
// VULNERABLE — JavaScript string comparison short-circuits
if (submittedToken === storedToken) { ... }
// SAFE — constant time regardless of where mismatch occurs
// By hashing both sides first, we only need to compare fixed-length hex strings
// createHash("sha256") produces 64-char hex strings
if (timingSafeEqual(
Buffer.from(submittedHash, "hex"),
Buffer.from(storedHash, "hex")
)) { ... }
By hashing the submitted token before database lookup, the database query itself prevents timing correlation — the query time depends on the index lookup, not character matching.
Security Properties Achieved
| Requirement | Implementation |
|---|---|
| 128+ bits entropy | 32 random bytes = 256 bits |
| Short expiry | 30-minute expiry enforced server-side |
| Single use | used_at field set atomically before password change |
| Hashed storage | SHA-256 of raw token stored, never raw token |
| No account enumeration | Identical response for existing/non-existing emails |
| Rate limiting | 5 requests/hour/IP via express-rate-limit |
| Timing attack prevention | Hash-then-compare, constant-time comparison |
| Session invalidation | All sessions revoked after password change |
Common Mistakes to Avoid
Never use sequential or predictable tokens: reset_${userId}_${timestamp} is trivially guessable.
Never store the raw token in the database: treat reset tokens like passwords — store only the hash.
Never reveal whether an email exists: the response to "email not found" must be identical to "email found and reset sent."
Never allow token reuse: mark tokens as used atomically with a database-level unique constraint.
Always invalidate sessions after password change: a user who changes their password due to account compromise needs all existing sessions terminated.