Blog

Password Reset Poisoning: How Faking One Header Hijacks an Account.

Poisoning
Linux / Linux

Password Reset Poisoning: How Faking One Header Hijacks an Account.

A user clicks “Forgot password”, enters their email, and waits for the message. It arrives. The link looks normal — a familiar domain, HTTPS. They click it.

At that moment the reset token is delivered to an attacker. Seconds later the attacker changes the password. The user tries to log in — the account is already someone else’s.

The attack is called Password Reset Poisoning. No malicious code, no server breach. Just one manipulated HTTP header.

WHY APPLICATIONS TRUST THE HOST HEADER

When a user requests a password reset, the application needs to build a link like:

https://mysite.com/reset?token=abc123def456

To do this, it needs to know the site’s domain name. Many frameworks read it from the HTTP Host header — the one a browser sends with every request. The logic seems obvious: “whatever domain is in the request, use it in the link.”

The problem is that the Host header is entirely controlled by the client. A browser fills it in automatically, but nothing stops someone from sending a request manually with any value they choose. The application does not verify — it simply trusts.

ANATOMY OF THE ATTACK, STEP BY STEP

Step 1. The attacker knows the victim’s email address — or just tries common ones. They open the password reset page on target.com.

Step 2. Instead of sending the request through a browser, the attacker sends it manually — via curl, Burp Suite, or any HTTP client. They replace the Host header:

POST /reset-password HTTP/1.1
Host: attacker.com
Content-Type: application/x-www-form-urlencoded

[email protected]

Step 3. The application receives the request, generates a token, and builds the link. It takes the domain from the Host header — which is attacker.com. The email goes to the victim with the link:

Reset your password: https://attacker.com/reset?token=a1b2c3d4e5f6

Step 4. The victim sees the email. The sender is the real site, the subject is real. The link only looks suspicious if you read the domain carefully. Most people don’t — they click.

Step 5. The victim’s browser makes a GET request to attacker.com/reset?token=a1b2c3d4e5f6. The attacker sees the token in their server logs.

Step 6. The attacker uses the token directly: https://target.com/reset?token=a1b2c3d4e5f6. Sets a new password. Account captured.

The victim never understood what happened — the email was real.

HOW TO TEST IN ONE COMMAND

You can check whether your site is vulnerable without any special tools:

curl -s -X POST https://target.com/forgot-password \
  -H "Host: attacker.com" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "[email protected]"

If an email arrives at [email protected] with a link pointing to attacker.com — the site is vulnerable.

The -s flag enables silent mode — suppresses progress output and error messages. -X POST sets the method. -H adds a header. -d is the request body. Only test on your own sites or with written permission from the owner.

REAL CASES

In 2013, researcher James Kettle from PortSwigger (creators of Burp Suite) systematized the class of attacks via HTTP Host header and published his findings. Before that, the vulnerability was known but not catalogued as a distinct attack class. His research prompted frameworks to begin closing the problem at scale. Django, Joomla, and Piwik issued patches shortly after.

CVE-2012-4520 — Django before version 1.4.2 did not properly validate the Host header. An attacker could substitute the domain in password reset emails. The vulnerability was closed by introducing the ALLOWED_HOSTS setting.

CVE-2025-63828 — Backdrop CMS 1.32.1, November 2025. The password reset form used the Host header to build the reset link without validation. Fixed in version 1.32.2. A fresh reminder that this mistake does not age — it is discovered in new products every year.

HackerOne publishes dozens of reports on this vulnerability annually — it appears in enterprise applications, SaaS platforms, and custom-built frameworks. The reason is always the same: a developer wrote request.get_host() where there should have been a constant from the config.

PROTECTION AT THE FRAMEWORK LEVEL

Django

In Django, password reset uses request.get_host() to build the link. Without ALLOWED_HOSTS — vulnerable. The setting is mandatory in production:

# settings.py
ALLOWED_HOSTS = ['mysite.com', 'www.mysite.com']

Django validates every incoming request — if Host is not in the list, it returns 400 Bad Request. The list must contain only the real domains of the site. No * in production.

Laravel

In Laravel 7.12+, the TrustedHosts middleware handles this — introduced in May 2020. Without it, the application accepts any Host. To enable it:

# app/Http/Middleware/TrustHosts.php
public function hosts(): array
{
    return [
        $this->allSubdomainsOfApplicationUrl(),
        'mysite.com',
    ];
}

Register it in app/Http/Kernel.php (Laravel 7–10):

protected $middleware = [
    \App\Http\Middleware\TrustHosts::class,
    // ...
];

In Laravel 11+ there is no Kernel.php — middleware is registered in bootstrap/app.php via the withMiddleware() method.

General principle for any framework

Never build URLs from the Host header. For password reset emails, the domain must come from the application config or database — from a value the developer explicitly set, not from whatever the client sent:

# Bad — taking domain from the client
reset_url = f"https://{request.get_host()}/reset?token={token}"

# Good — taking domain from config
reset_url = f"{settings.BASE_URL}/reset?token={token}"

WordPress

WordPress core builds the reset link from siteurl stored in the database — not from the Host header. So the classic password reset poisoning where the link points to the attacker’s site does not work in WordPress core.

However, CVE-2017-8295 revealed a related vulnerability: WordPress used the SERVER_NAME variable for the From and Return-Path headers in outgoing emails. If the server passed the client’s Host header into SERVER_NAME, an attacker could forge the sender address. The email would go out with a fake From, and if delivery failed — it would bounce back to the attacker’s address along with the token. The vulnerability affected WordPress 2.3–4.7.4 and was fixed in 4.7.5.

For the attack to work, three conditions must be met simultaneously: a vulnerable version of WordPress, a web server that does not validate the Host header and passes its value to PHP — this is exactly how Apache behaves without an explicit ServerName — and the email must fail to deliver and bounce back to the sender address. If any one of these conditions is not met, the attack fails. On nginx with a configured default_server and return 444, a request with a foreign Host never reaches WordPress at all — closed at the infrastructure level.

Modern WordPress core is protected. The risk remains in plugins — if a plugin builds URLs from $_SERVER['HTTP_HOST'] for sending emails, it is vulnerable regardless of the WordPress version.

PROTECTION AT THE NGINX LEVEL

Host header validation in nginx is the first line of defense. Requests with an unknown Host must not reach the application:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

server {
    listen 80;
    server_name mysite.com www.mysite.com;
    # only these hosts reach the application
}

If nginx returns 444 on all unknown hosts — the attacker simply gets no response and the attack fails. Even if the application is vulnerable — the request never reaches it.

Verify the protection works:

curl -v -X POST https://mysite.com/forgot-password \
  -H "Host: attacker.com" \
  -d "[email protected]"
# Expected result: Empty reply from server

TYPICAL MISTAKES

Setting ALLOWED_HOSTS = ['*'] to quickly fix an error in development — and forgetting to remove it before deploying. The asterisk disables all of Django’s protection. In production this is the same as having no setting at all.

Thinking that HTTPS protects against Host header substitution. It does not — TLS encrypts the transport, but the headers inside it are still controlled by the client.

Checking the Host only on the main page and forgetting about /api/reset-password, /auth/forgot, and other endpoints. The attacker goes directly there.

Using the server’s IP address as the only value in ALLOWED_HOSTS or TrustedHosts and forgetting to add the domain name. Reset emails end up with links pointing to an IP address.

CONCLUSION

Password Reset Poisoning is a one-line attack. No complex tooling, no infrastructure breach. One manipulated header, one email — and the account is captured.

You should be concerned if you run any site with a password reset form — especially if it is Django without an explicit ALLOWED_HOSTS, Laravel without TrustedHosts, or WordPress with custom plugins that send emails themselves. CVE-2025-63828 in Backdrop CMS was only fixed in November 2025 — this mistake appears in new products every year because developers don’t think about where the domain in an email actually comes from.

Protection works on two levels. At the infrastructure level — nginx with return 444 on unknown hosts closes the attack before it even reaches the application. At the code level — the domain in emails always comes from config or the database, never from what the client sent.

If your site has a password reset form — test it right now with the one-liner from the section above.

Leave your thought here

Your email address will not be published. Required fields are marked *