All posts
VibeZero Team15 min read

Your app asked the attacker where to send the reset link

Password reset poisoning lets an attacker choose the link in your reset email. How the Host header does it, and where the reset URL should come from instead.

  • security
  • vibe coding
  • engineering

A password reset link is a bearer token with an inbox for a lock. Anyone holding the token is the account, which is the entire design: the mailbox is the proof. Password reset poisoning is the attack that removes the mailbox from that sentence, by getting your own server to write the attacker’s domain into the link before it is sent.

The request that starts it is the one your app is built to accept. The attacker types the victim’s email address into your forgot-password form, and changes one header on the way out. Your app looks up a real account, mints a real token, composes a real email, and sends it to the real owner. Everything works. The only thing that is wrong is the hostname in the URL, and the victim has no way to see that it is wrong, because the mail genuinely came from you.

The Host header is user input

The mechanism is a substitution that feels safe and is not: the app needs to know its own address in order to build an absolute URL, and it asks the request.

Django’s own security documentation states the consequence plainly. Django “uses the Host header provided by the client to construct URLs in certain cases,” and while those values are sanitized against XSS, “a fake Host value can be used for Cross-Site Request Forgery, cache poisoning attacks, and poisoning links in emails.” That is why ALLOWED_HOSTS exists at all, and the framework describes it as necessary “because even seemingly-secure web server configurations are susceptible to fake Host headers.” Note the last sentence of that section, which is the one that catches people who think they are covered: the validation “only applies via get_host(); if your code accesses the Host header directly from request.META you are bypassing this security protection.”

The same shape in a generated Next.js route handler, which is roughly what a prompt like “add password reset” produces:

// app/api/forgot-password/route.ts
// Vulnerable: the origin of the emailed link is whatever the caller claimed.
export async function POST(request: Request) {
  const { email } = await request.json();
  const token = crypto.randomUUID();
  await saveResetToken(email, token);

  const host =
    request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  await sendResetEmail(email, `https://${host}/reset-password?token=${token}`);

  return Response.json({ ok: true });
}

Two headers there, and the second one is worse than the first. Host is at least sometimes pinned by the web server or the platform’s router before your code runs. X-Forwarded-Host is an ordinary request header with no validation anywhere in the stack, invented so that a proxy could tell the origin server what the client originally asked for, and it is trusted here for exactly that reason. PortSwigger’s write-up of the class puts the condition in one line: “if the URL that is sent to the user is dynamically generated based on controllable input, such as the Host header, it may be possible to construct a password reset poisoning attack.”

There is a quieter variant worth knowing, because it survives a fix that only covers the link. If the Host header reaches any other part of the message, an attacker can inject markup into the email body, or change who the message appears to come from. Which is what happened to WordPress.

CVE-2017-8295 is the example everyone cites, and almost everyone describes it wrong. The reset link was fine. Dawid Golunski published the advisory on 3 May 2017, against WordPress through 4.7.4. The vulnerable code was not in the reset flow at all. It was in the mailer, in wp-includes/pluggable.php, deciding what address the site sends mail from:

$sitename = strtolower($_SERVER['SERVER_NAME']);
if (substr($sitename, 0, 4) == 'www.') {
    $sitename = substr($sitename, 4);
}
$from_email = 'wordpress@' . $sitename;

SERVER_NAME sounds like a server-side value, and that is the trap. Apache’s default is UseCanonicalName Off, under which the server “will use the hostname and port supplied by the client in the Host header to construct the SERVER_NAME and SERVER_PORT CGI variables.” A request header, wearing the name of a configuration value. So a crafted wp-login.php?action=lostpassword request set Return-Path, From and Message-ID to the attacker’s domain, on a real reset email carrying a real token, delivered to the real victim.

The token was in the victim’s inbox the whole time. The attack was to get a copy of that inbox message to bounce back to the attacker’s mail server, and the advisory is honest that this needs one of three conditions: the attacker can stop the victim receiving mail for long enough that the message bounces (a full mailbox, or an extended flood), the victim’s mail system sends an autoresponse quoting the original, or the victim simply replies to the message asking what is going on. Every one of those is ordinary mailbox behavior rather than a second vulnerability.

The URL has to come from configuration

OWASP’s Forgot Password Cheat Sheet gives the remediation in one sentence, and it is a short list of two options: “Don’t rely on the Host header while creating the reset URLs to avoid Host Header Injection attacks. The URL should either be hard-coded, or validated against a list of trusted domains.”

Our own control plane takes the first option, and the reason it is worth describing is that the property comes from the type rather than from a check. The service holds one parsed Url read from an environment variable at startup, and every tokenized link is built from it:

// The base is a parsed Url from config, never a header. `path` is trimmed so it
// cannot produce a double slash against a base that already ends in one.
pub(super) fn app_link(base: &Url, path: &str, token: &str) -> String {
    let mut url = base.clone();
    url.set_path(&format!(
        "{}/{}",
        url.path().trim_end_matches('/'),
        path.trim_matches('/')
    ));
    url.query_pairs_mut().append_pair("token", token);
    url.to_string()
}

One variable backs all three links we mail (verification, password reset, and the welcome email’s button), so there is a single place where the origin is decided and no code path that can reach a request from inside the mailer. Getting it wrong requires deleting a config value and passing a header in, which is a visible edit, rather than forgetting a check, which is not.

The equivalent in the Next.js route above is smaller than the vulnerable version:

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

// One origin, decided at deploy time. Parsed at module load, so a missing
// variable throws on import instead of quietly emailing https://undefined/.
const APP_ORIGIN = new URL(process.env.APP_ORIGIN!);

export async function POST(request: Request) {
  const { email } = await request.json();
  // 32 bytes from the CSPRNG, not a guessable id. Store only its SHA-256.
  const token = randomBytes(32).toString("hex");
  await saveResetToken(email, createHash("sha256").update(token).digest("hex"));

  const link = new URL("/reset-password", APP_ORIGIN);
  link.searchParams.set("token", token);
  await sendResetEmail(email, link.toString());

  return Response.json({ ok: true });
}

The token goes in the query string here because it has to: this is a URL a human clicks out of an email client, and there is no other place to put it. That is worth noticing rather than accepting, because it means the secret is now in a URL, and URLs travel. The endpoint that redeems the token should take it in a request body instead, where it stays out of access logs, and the redemption should be a POST for the same reason.

On Supabase and Firebase you did not write the mailer

Most AI-generated apps do not have a route like the one above, because the platform sends the mail. That does not remove the decision, it moves it into a dashboard setting, which is a worse place for it to live because nothing in the codebase mentions it.

On Supabase, resetPasswordForEmail(email, { redirectTo }) builds the link from the project’s Site URL, and redirectTo overrides it only if the value matches the Redirect URLs allowlist. If it does not match, Supabase falls back to the Site URL, which is a safe default and also a silent one: a misconfigured redirectTo looks like a routing bug, not a security event. That allowlist is the entire defense, and it supports wildcards: * matches any run of non-separator characters, ** matches anything at all, and the separators are . and /. So https://** is an entry that accepts every host on the internet, and https://*.vercel.app accepts every preview deployment anyone has ever pushed, including someone else’s. Supabase’s own guidance is to narrow it before launch: “while the ‘globstar’ (**) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production.”

This is the same failure as an open redirect, with the same fix (exact origins, not suffix matches) and a worse payload, because the thing being redirected is not a browsing session but a single-use credential. It is also the same shape as the wildcard problems in a Bolt app’s CORS configuration: the wildcard is added during development because it makes the localhost loop work, and nothing ever fails afterwards to remind anyone it is still there.

Firebase works the same way with different nouns: sendPasswordResetEmail takes an ActionCodeSettings whose url is the continue URL, and Firebase is explicit that “in order to securely pass a continue URL, you need to add the domain for the URL as an authorized domain” under Authentication settings. Historically that list included localhost out of the box, which is worth knowing if your project predates 28 April 2025, because newer projects no longer get it and the entry, if you have one, was put there rather than left there.

Three settings to check on any project you did not configure yourself, and all three are two-minute checks:

  1. Site URL (Supabase) or Authorized domains (Firebase) points at your production origin, not at localhost:3000. This is the value used when nothing else matches, so it is the one that decides where a reset link goes in the default case.
  2. The redirect allowlist has no globstar entry, no bare https://, and no wildcard over a shared hosting domain like *.vercel.app or *.netlify.app. Preview URLs are worth an exact entry each, or a wildcard on a subdomain you own outright.
  3. localhost entries are gone from production. They are not exploitable by themselves, but they are the marker that the list was never reviewed, and the list is the whole control.

If your Supabase project also has the anon key doing work the service role key should be doing, that is a different problem in the same dashboard, and it is covered in how to secure a Lovable app.

Poisoning is how the token goes to the wrong place before delivery. There is a second family of leaks that happens after the right person clicks the right link, and they matter here because the token is still live at that moment: the reset page is loaded, the token is in the address bar, and nothing has been consumed yet.

The full URL of that page is visible to more parties than most people expect, and it is worth being precise about which, because the folklore here is out of date. Since a 2020 change to the Fetch spec, browsers default to strict-origin-when-cross-origin with no header at all, so a cross-origin click off your reset page sends only your origin. What that default still sends in full is the same-origin case: “the origin, path, and query string when performing a same-origin request.” Your own analytics endpoint, your own API, and your own access logs therefore see the token, and so does every script running on the page, which can simply read document.location. Analytics tags, session replay and chat widgets are all in that category, and none of them is affected by any referrer policy.

Set the header anyway. Our free scanner flags its absence as no-referrer-policy, a LOW, and the finding text names this exact case: it leaks “anything you keep in a path or a query string, such as reset tokens and internal identifiers.” Sending Referrer-Policy: strict-origin-when-cross-origin explicitly costs nothing, covers user agents that do not implement the modern default, and turns a browser behavior you are relying on into a property of your site that a reviewer (or our security headers checker) can confirm. Just do not mistake it for the fix. A stricter value such as no-referrer would also close the same-origin case, but no policy of any kind stops a script on the page from reading the address bar directly, and that is the leak a reset page actually has. Which makes the practical control a different question: what is loaded on that page at all.

Supabase’s flows are worth reading closely here, because the two of them make opposite trades. The implicit flow puts the tokens in the URL fragment, and the docs give the reasoning: “web browsers do not send the URL fragment to the server by design,” which matters because “GET requests and their full URLs are often logged” and a third-party host “shouldn’t get access to your user’s credentials.” A fragment is invisible to the server and to the Referer header, and fully visible to every script on the page. The PKCE flow puts a code in the query string instead, which is exactly the position that leaks to same-origin referrers and logs, and compensates by making it nearly worthless to hold: the code “has a validity of 5 minutes and can only be exchanged for an access token once.”

That second design is the better one, and the principle generalizes past Supabase. You cannot keep a token out of every log and every script on a page whose whole purpose is to receive that token in a URL. What you can do is shrink the window in which a copy is worth anything.

One use, one hour, and every session gone

Which brings us to the part of the flow that is easy to get almost right. Four properties, and the first is the one AI-generated code most often gets wrong, by writing it as a SELECT that checks the token followed by an UPDATE that spends it.

Single use has to be atomic. A read-then-write leaves a window where two requests both see a valid token, and “the attacker replays the link a second time” is not a theoretical race. Ours is one statement, and the row either moves or it does not:

-- Consume a reset token: the UPDATE itself is the check, so a concurrent
-- redemption of the same link loses deterministically and gets the same 400.
UPDATE password_reset_tokens SET consumed_at = now()
 WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING user_id;

Expiry rides along in the same predicate rather than living in a separate check, which is why there is no window where a token is expired but still redeemable. Ours last an hour. That number is a trade rather than a standard: long enough that a link survives someone reading their mail after lunch, short enough that a copy sitting in a log or a mailbox backup is usually already dead.

Store the hash, not the token. The reset table is a table of live account takeovers if it is ever read, whether by SQL injection, a leaked backup, or an overly generous read policy. We store a hex SHA-256 of a 32-byte value from the OS CSPRNG, and the fast hash is fine here for the reason it is not fine for passwords: the input has 256 bits of entropy, so there is nothing to guess.

Keep the ledgers separate. Verification tokens and reset tokens are two tables here, not one table with a purpose column. The two flows mint tokens from the same function, and they look identical, so a single shared table would put token confusion, a verification token accepted at the reset endpoint, one missing predicate away. That is account takeover reachable by anyone who can trigger a verification email, which is to say anyone. Separate tables make the predicate structural: a token minted for one flow cannot be found by the other’s query at all.

Finish by revoking the sessions. A reset that leaves the attacker’s existing session alive has not recovered the account, it has changed a password the attacker no longer needs. OWASP’s cheat sheet asks you to “invalidate all of their existing sessions, or invalidate the sessions automatically,” and automatically is the right reading for a flow that exists because something went wrong. Ours closes every session in the same transaction that consumes the token, and therefore returns no session token of its own, because minting one would contradict the revocation the user just asked for. If your sessions are stateless JWTs, this is the step you cannot perform, which is the concrete cost described in JWT expiration: a token you did not store is a token you cannot cancel, and the reset flow is where that bill arrives.

Then tell the account it happened. A “your password was changed” email is the only signal a victim gets that any of this occurred, and it costs nothing. Send it best-effort and off the request path: the password is already changed, and a mail provider having a bad afternoon must not turn a completed reset into an error the user retries.

The mailbox was the whole authentication

Strip the flow down and it is one claim: whoever reads this inbox owns this account. Every control in this post is a way of keeping that claim true. The Host header check keeps the message going to the mailbox. The allowlist keeps the click coming back to you. Watching what the reset page loads keeps the token from being read off the address bar by someone else’s script. Single use and expiry keep a leaked copy from mattering an hour later. Session revocation makes the reset mean something.

Password reset is the one flow that is designed to bypass authentication, so it is the one flow where a bug is not a step toward account takeover but the whole thing at once. It is also, reliably, the last feature added to an app and the one written fastest, usually late, usually because a user got locked out. A generator asked for “add forgot password” will produce a working flow, and working is a low bar for a feature whose entire job is to decide who someone is based on a message they can no longer prove they received.

So the question to ask is not whether reset works. It is where the link in that email came from, and if the answer is the request, someone else can answer it for you.

ShareXLinkedIn