highAuthentication

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

Vulnerableroutes/reset.ts
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 });
});
Fixedroutes/reset.ts
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.

Frequently Asked Questions

How long should a password reset token be valid?
Reset tokens should expire within 15 to 30 minutes. This gives users enough time to check their email and complete the reset, while limiting the window for an attacker to intercept or brute-force the token. Always invalidate the token immediately after successful use.
Why should I hash the reset token before storing it?
If an attacker gains read access to your database (through SQL injection or a backup leak), they should not be able to use the stored tokens to reset passwords. By hashing the token with SHA-256 before storage and comparing hashes during validation, a database leak does not compromise active reset tokens.
Should the forgot password endpoint reveal whether an email exists?
No. Always return the same response regardless of whether the email is registered. Saying 'email not found' lets attackers enumerate valid accounts. Use a generic message like 'If an account exists with this email, a reset link has been sent' for both cases.

Related Security Topics

Check Your Code for This Vulnerability

Run a free scan to check if your site is affected by insecure password reset flow.