Insecure Password Reset Flow
What Is This Vulnerability?
Insecure password reset flows contain weaknesses that allow attackers to reset another user's password and take over their account. Common issues include predictable reset tokens, tokens that do not expire, reset links sent over unencrypted channels, and lack of rate limiting on reset requests that enables brute-force attacks against short tokens.
Why It Happens
Password reset is a complex flow that intersects security, email delivery, and user experience. Developers sometimes generate tokens using weak randomness (timestamps, sequential numbers, or short codes), skip expiration logic to avoid user complaints, or do not invalidate tokens after use. The reset flow gets less testing attention than the login flow.
Example Code
app.post("/forgot-password", async (req, res) => {
const { email } = req.body;
const user = await db.query("SELECT id FROM users WHERE email = $1", [email]);
if (!user.rows[0]) return res.json({ message: "If the email exists, a reset link was sent" });
const token = Date.now().toString(36);
await db.query(
"UPDATE users SET reset_token = $1 WHERE id = $2",
[token, user.rows[0].id]
);
await sendEmail(email, `Reset: https://app.com/reset?token=${token}`);
res.json({ message: "If the email exists, a reset link was sent" });
});
app.post("/reset-password", async (req, res) => {
const { token, newPassword } = req.body;
const hash = crypto.createHash("md5").update(newPassword).digest("hex");
await db.query(
"UPDATE users SET password_hash = $1, reset_token = NULL WHERE reset_token = $2",
[hash, token]
);
res.json({ success: true });
});import crypto from "crypto";
import bcrypt from "bcrypt";
app.post("/forgot-password", async (req, res) => {
const { email } = req.body;
const user = await db.query("SELECT id FROM users WHERE email = $1", [email]);
if (!user.rows[0]) return res.json({ message: "If the email exists, a reset link was sent" });
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes
await db.query(
"UPDATE users SET reset_token_hash = $1, reset_expires = $2 WHERE id = $3",
[tokenHash, expiresAt, user.rows[0].id]
);
await sendEmail(email, `Reset: https://app.com/reset?token=${token}`);
res.json({ message: "If the email exists, a reset link was sent" });
});
app.post("/reset-password", async (req, res) => {
const { token, newPassword } = req.body;
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const user = await db.query(
"SELECT id FROM users WHERE reset_token_hash = $1 AND reset_expires > NOW()",
[tokenHash]
);
if (!user.rows[0]) return res.status(400).json({ error: "Invalid or expired token" });
const hash = await bcrypt.hash(newPassword, 12);
await db.query(
"UPDATE users SET password_hash = $1, reset_token_hash = NULL, reset_expires = NULL WHERE id = $2",
[hash, user.rows[0].id]
);
res.json({ success: true });
});How Hackers Exploit It
If tokens are based on timestamps, an attacker who knows approximately when a reset was requested can brute-force the token in seconds. Short numeric codes (like 4-6 digit OTPs without rate limiting) are similarly vulnerable to automated guessing. If tokens never expire, a leaked or logged URL from months ago can still be used. Some attackers trigger resets for target accounts and intercept the email through compromised mail servers or network sniffing.
How to Fix It
Generate tokens using crypto.randomBytes(32) for sufficient entropy. Hash the token before storing it in the database (so a database leak does not expose valid tokens). Set a short expiration window (15-30 minutes). Invalidate the token after use. Implement rate limiting on both the request and submission endpoints. Require the user to re-authenticate after a password reset.