Password reset vulnerabilities: how they work, how to test them and how to fix them

Password reset vulnerabilities are flaws in the forgot-password flow that let someone other than the account owner set a new password. Common causes are a reset link built from the Host header, guessable or reusable tokens, no expiry, and leaking whether an account exists. CWE-640 covers the class. Fix it with random single-use tokens, a fixed base URL and equal responses.

Password reset vulnerabilities are flaws in the forgot-password flow that let someone other than the account owner set a new password or learn something about the account. CWE-640 describes the class as a mechanism "for users to recover or change their passwords without knowing the original password" that is weak. A reset flow is a second way to log in, so an attacker who breaks it gets account takeover without ever guessing the password.

Testing the flow needs written authorization from the system owner, and test-owned accounts and inboxes only. Everything below is for defenders, testers and developers.

What does a safe password reset flow look like?#

A safe flow has six steps, and each flaw below is a step that is missing or weak. OWASP's Forgot Password Cheat Sheet sets the requirements for most of them.

  1. The user enters an email address or username. The app answers with the same message whether or not the account exists, and takes the same time to answer.
  2. The app creates a token with a cryptographically secure random number generator, ties it to one user record and stores it safely.
  3. The app emails a link with the token in the query string. The base URL is fixed in configuration, never taken from the request.
  4. The user opens the link over HTTPS. The page sets the no-referrer Referrer Policy so the token does not leak to other sites.
  5. The user enters a new password twice. The app applies the normal password policy and marks the token as used.
  6. The app ends or offers to end other sessions, invalidates outstanding reset links, and emails the user a confirmation that does not contain the password.

OWASP also says not to lock the account after repeated reset requests, because that lets an attacker lock out a known username. Limit requests per account or add a CAPTCHA instead.

How does a reset flaw work in practice?#

Most reset flaws come from one trusted value that the attacker can change. The most common is the Host header, which many frameworks use to build absolute URLs. PortSwigger describes the attack as "password reset poisoning": the attacker triggers a reset for the victim and the emailed link points to a host the attacker controls.

Illustrative reset poisoning: Harbor Notes

This is a demonstration. No real customer system was tested. Harbor Notes is a fictional app and example.com is a reserved example domain.

A tester with two test accounts, ana@harbor-notes.example.com (the "victim") and tester@harbor-notes.example.com, sends a reset request for the victim with an extra header:

POST /forgot-password HTTP/1.1
Host: app.harbor-notes.example.com
X-Forwarded-Host: lab.example.net
Content-Type: application/x-www-form-urlencoded

email=ana@harbor-notes.example.com

The tester controls lab.example.net and the test inbox. A vulnerable app sends the victim a message with this link:

https://lab.example.net/reset?token=<token>

A control request without the extra header produces a link on app.harbor-notes.example.com. The difference is the evidence. If the victim opens the link, the token reaches a log on lab.example.net, and the tester can then use it on the real host with a test password. A safe app builds the link from a configured base URL and the two requests give identical emails.

The same pattern applies to other inputs. Anything the attacker can change and the app trusts, such as the email parameter, a redirect parameter in the link or a token that the server can compute, can break the flow.

What are the main types of password reset flaws?#

There are 12 flaws testers check. The table gives each one a test, a fix and a typical severity range. Severity depends on the app: a reset that gives takeover of any account is usually Critical or High, and a leak that only confirms an email address is usually Low.

Flaw Test Fix Typical severity
Host header poisoning Add X-Forwarded-Host or change Host, read the emailed link Fixed base URL in config, trusted-host list High to Critical
Predictable token Request several tokens for test accounts, compare structure, time and length CSPRNG token, 128 bits or more Critical
Token reuse Use a token twice Mark token used on first success High
No expiry Use a token after the expiry window you set in scoping Short lifetime, enforced on the server Medium to High
Token not tied to the user Send a valid token with another account's identifier Look up the token, then the user from the token Critical
Older tokens stay valid Request two tokens, use the first after the second Invalidate older tokens on a new request Medium
Token leaks in Referer Click the link, check outgoing requests from the reset page Referrer-Policy: no-referrer, no third-party scripts on the page Medium
Account enumeration Compare body, status and timing for known and unknown accounts Same message and equal timing Low
No rate limit Send a bounded set of requests on a test account Limit per account and per source Medium
Sessions survive reset Log in on two browsers, reset, read back from the old session End or invalidate sessions after reset Medium to High
Token or link in the response body Read the API response of the request step Never return the token to the requester Critical
Reset skips MFA Reset on a test account that has MFA enabled Require the second factor or a verified recovery path High

Two more variants appear in testing reports. Email parameter pollution sends two addresses in one request, and a vulnerable app emails the token to both. Weak tokens built from a timestamp or a hash of the email can be reproduced by anyone who knows the inputs. Both fall under the rows above: the first is a token delivered to the wrong person, the second is a predictable token.

What are the real cases?#

The clearest public case is Craft CMS. NVD records CVE-2022-29933, published 9 May 2022: Craft CMS through 3.7.36 let a remote unauthenticated attacker who knew a valid username reset the account password and take over the account by sending an X-Forwarded-Host header to the password reset email endpoint. NVD lists the weakness as CWE-640. It also records the vendor's position that customers can work around the issue by changing the default configuration. That detail matters for testers: a default configuration that trusts forwarded headers is itself a finding.

Frameworks document the same risk. Laravel's documentation says its absolute URLs use the Host header value, that the trustHosts middleware restricts hostnames, and that this is "particularly important when your application offers password reset functionality."

For weak token cases, search NVD for CWE-640 entries. Each entry names the product, the version and the fixed release, which gives you a dated reference for a report.

How do penetration testers detect and prove a reset flaw?#

Testers prove a reset flaw by completing the attack on test-owned accounts and reading the result back. The method follows the evidence standard in our methodology: controls run next to the exploit request, and a bare success message is not proof.

  1. Create two test accounts and a test inbox for each. Mark them as test data.
  2. Request a reset for account A and record the request, the response and the email with timestamps.
  3. Run the controls: an unknown email, a token from account B used with account A, a token with one character changed, and a request with no extra headers.
  4. Replay the token twice and after a second token is issued. Record which attempts succeed.
  5. Read back. Log in with the new password on account A. Check that an older session on account A still works or has ended.
  6. Browser-observed checks use Chromium. Open the reset page, watch the network panel and confirm the Referer sent to third-party hosts.

Bounded testing applies to everything that sends email or guesses tokens. Agree the number of requests during scoping and state it in the report. Do not brute force a token space on a production system without written approval. Report the controls that held as well. If an unknown-email request returns the same page and timing as a known one, say so, so that a later fix does not break it.

How do you prevent password reset vulnerabilities?#

Prevent them by using your framework's reset implementation, keeping the base URL in configuration and following the OWASP requirements for tokens, expiry and responses. Django, Laravel and Rails (Devise) ship tested reset flows. Writing your own is where most of the flaws above appear.

Framework behavior that the documentation states:

  • Django sends a reset email only if the address exists, the user is active and the user has a usable password. In every case the user is redirected to the same page. The documentation warns that sending an email takes time, so a timing difference can still reveal which addresses exist.
  • Laravel's password broker handles tokens and throttling. Its cache driver example sets expire to 60 and throttle to 60. Restrict hostnames with trustHosts.

If you build your own flow, the pattern is the same in every language: random token, hash at rest, expiry, single use, fixed base URL.

Python:

import secrets, hashlib, datetime as dt

def create_reset(user_id, db):
    token = secrets.token_urlsafe(32)  # 256 bits from the OS CSPRNG
    digest = hashlib.sha256(token.encode()).hexdigest()
    db.save(user_id=user_id, digest=digest,
            expires=dt.datetime.utcnow() + dt.timedelta(minutes=30))
    return token  # send by email only, never return it in the API response

Node.js:

import { randomBytes, createHash } from "node:crypto";

const BASE_URL = process.env.PUBLIC_BASE_URL; // fixed config, never req.headers.host

export async function createReset(userId, db) {
  const token = randomBytes(32).toString("base64url");
  const digest = createHash("sha256").update(token).digest("hex");
  await db.saveReset({ userId, digest, expiresAt: Date.now() + 30 * 60 * 1000 });
  return `${BASE_URL}/reset?token=${token}`;
}

Java:

SecureRandom rng = new SecureRandom();
byte[] raw = new byte[32];
rng.nextBytes(raw);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
String digest = HexFormat.of().formatHex(
    MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8)));
// store digest + expiry + userId; email the link built from a configured base URL

PHP:

$token = bin2hex(random_bytes(32));
$digest = hash('sha256', $token);
// store $digest, user id and expiry; build the link from config('app.url'), not $_SERVER['HTTP_HOST']

On the confirm step, look up the digest, check the expiry, set the password, delete the token and every other token for that user, and end the user's sessions. A hash of a random 256-bit token needs no salt because it cannot be guessed. Compare digests in constant time where your stack offers it.

Important

Never build the reset link from Host, X-Forwarded-Host or request.get_host() unless the value is checked against a list of trusted domains. OWASP says the URL "should either be hard-coded, or validated against a list of trusted domains."

Reset and MFA must work together. OWASP's multifactor cheat sheet says that processes for resetting MFA "may be exploitable by attackers" and that account recovery "should be no weaker than regular authentication". See 2FA bypass for the tests.

Password reset testing checklist#

Use this list on a test environment with test accounts and written authorization.

  • The reset request returns the same body, status and timing for known and unknown accounts.
  • The emailed link uses the configured host even when Host or X-Forwarded-Host is changed. See host header injection.
  • Tokens are random, long, tied to one user and stored as a hash.
  • A token expires on the server after the agreed window.
  • A token works once. A newer token invalidates an older one.
  • A token with a changed character, or from another account, fails with the same message as an expired token.
  • The API response of the request step does not contain the token or the link.
  • The reset page sends no Referer to other hosts, and has no third-party scripts.
  • A second email address in the same request does not receive the link.
  • Requests and token attempts are limited per account and per source. See rate limiting and credential stuffing.
  • Existing sessions end after the reset, and the user gets a confirmation email.
  • A reset on an account with MFA does not skip the second factor.

For the full authentication scope, use the authentication testing checklist.

When should you get a professional test?#

Get a professional test when the reset flow protects accounts with money, personal data or admin rights, or before a launch or a customer security review. Reset flows break in ways that depend on your mail provider, proxy and framework settings, so a scanner rarely finds them. Our web application penetration testing covers authentication and account recovery with two test accounts per role, controls and read-back evidence. Excession Labs agrees the scope with you in writing before any testing starts.

Need a pentest you can act on?

Tell us what you need tested and why. We reply to agree scope, and testing starts only after written authorization.

Questions

What is password reset poisoning?

It is a reset flaw where the app builds the emailed link from the Host header, or a header such as X-Forwarded-Host, that the requester controls. The victim gets a real email whose link points to a host the attacker controls, and the token can leak when the victim clicks. PortSwigger documents it, and the Craft CMS case below is an example.

How long should a password reset token be valid?

OWASP says tokens should expire after an appropriate period and does not give a number. Pick a short window that fits your users, for example 15 to 60 minutes, and make each token single use. Django's PasswordResetTokenGenerator uses a setting for the lifetime. Check your framework's default.

Should the forgot-password page say that an email is not registered?

No. OWASP says to return the same message for existing and nonexistent accounts, and to match response timing too. Django does this by default: it redirects to the same page and sends no email when the address is unknown.

Does a password reset need to log the user out of other sessions?

Yes. OWASP says to offer to end all sessions or invalidate them automatically, and not to log the user in automatically after a reset. A reset after a suspected takeover must also invalidate outstanding reset links and codes.

How do pentesters test a password reset flow?

They use two test accounts and a test inbox. They capture a token, replay it, test expiry, request tokens with altered headers, compare responses for known and unknown accounts, and check that sessions end. A finding needs evidence of a real effect, such as a login with the changed password.

Sources

  1. OWASP Forgot Password Cheat Sheet (checked 9 November 2026)
  2. CWE-640: Weak Password Recovery Mechanism for Forgotten Password (checked 9 November 2026)
  3. PortSwigger Web Security Academy: Password reset poisoning (checked 9 November 2026)
  4. NVD: CVE-2022-29933 (Craft CMS) (checked 9 November 2026)
  5. Django documentation: PasswordResetView (checked 9 November 2026)
  6. Laravel 12.x documentation: Resetting passwords (checked 9 November 2026)
  7. OWASP Multifactor Authentication Cheat Sheet (checked 9 November 2026)