Host Header Injection: How a Spoofed Header Hijacks Password-Reset Links
Applications that trust the Host header to build absolute URLs can be tricked into emailing attacker-controlled reset links and poisoning caches. Here is how it works and how to validate your way out.
Host: evil.attacker.comOn this page
Every HTTP request carries a Host header naming the site the client wants — Host: shop.example. It is essential for virtual hosting, where one server answers for many domains. The problem is that the Host header is client-supplied: the value comes from whoever made the request, not from the server's own configuration. Treating it as trustworthy is the mistake at the heart of host header injection.
When an application reflects that header into application logic — building absolute URLs, generating password-reset links, or computing cache keys — an attacker who controls the header controls those outputs. The result is poisoned links pointing at attacker domains, hijacked account-recovery flows, and cache poisoning that affects every later visitor.
How it happens
The root cause is code that asks the request "what host am I?" instead of reading a configured value. Absolute-URL construction is the classic offender, and password reset is where it bites hardest:
@app.route('/forgot-password', methods=['POST']) def forgot_password(): user = find_user(request.form['email']) token = create_reset_token(user) host = request.headers.get('Host') link = f'https://{host}/reset?token={token}' send_email(user.email, f'Reset your password: {link}') return 'Check your email'
The reset link's domain is taken straight from the inbound Host header. The developer assumed it would always be shop.example. Nothing guarantees that. Reverse proxies make it worse: many apps also trust X-Forwarded-Host, X-Host or Forwarded, any of which an attacker can set, and which can override the apparent host even when the front-end looks correct.
A concrete attack
The attacker triggers a password reset for the victim's account but rewrites the Host header to point at their own server:
POST /forgot-password HTTP/1.1
Host: evil.attacker.com
Content-Type: application/x-www-form-urlencoded
[email protected]
The application generates a valid reset token for the victim, then builds the link using the spoofed host:
https://evil.attacker.com/reset?token=eyJhbGciOi...
That link is emailed to the real victim. When they click it — trusting the email because they really did request a reset — their browser sends the genuine token to evil.attacker.com. The attacker captures it, visits the legitimate /reset?token=... endpoint, and takes over the account. The same trick poisons caches: if a shared cache stores a page whose absolute links were built from the attacker's host, every subsequent visitor is served those rewritten links.
The token was valid. The email was genuine. The only thing the attacker controlled was a header the application should never have trusted — and that was enough to redirect the secret to them.
What an attacker gains
Trusting the Host header turns a request header into a lever over the whole application:
- Account takeover via password-reset links that deliver a valid token to an attacker domain.
- Web cache poisoning when absolute URLs or resources built from the spoofed host get cached and served to others.
- Phishing with a legitimate footprint — emails and pages that originate from the real backend but point victims at malicious hosts.
- Routing and access-control abuse where downstream logic keys off the unvalidated host value.
The fix: validate the host against an allowlist
The header is untrusted input. Compare it against a configured set of canonical domains you actually serve, and reject anything else — then build URLs from a trusted, configured base rather than from the request.
ALLOWED_HOSTS = {'shop.example', 'www.shop.example'}
CANONICAL_BASE = 'https://shop.example'
@app.before_request
def enforce_host():
host = request.headers.get('Host', '')
if host not in ALLOWED_HOSTS:
abort(400)
@app.route('/forgot-password', methods=['POST'])
def forgot_password():
user = find_user(request.form['email'])
token = create_reset_token(user)
link = f'{CANONICAL_BASE}/reset?token={token}'
send_email(user.email, f'Reset your password: {link}')
return 'Check your email'
The rules that keep this safe:
- Validate
Hostagainst an explicit allowlist of expected domains and reject requests that do not match. - Build absolute URLs from a configured canonical host, never from the inbound header.
- Do not trust forwarding headers —
X-Forwarded-Host,X-HostandForwardedare attacker-controllable; only honor them from a proxy you control, and validate them the same way. - Most servers and frameworks offer an allowed-hosts setting — turn it on so spoofed hosts are rejected before any handler runs.
How SelfSec proves this one
Scanner behaviour- Module
- Host Header Injection
- How it probes
- Host, X-Forwarded-Host and routing-header variants against password-reset, absolute-URL and cache-relevant flows.
- How it confirms
- The poisoned host has to appear in a generated link, redirect or cached response rather than only in the request that carried it.
Reported as CWE-644 · ATT&CK T1190 · SARIF 2.1.0
A reflected response alone is not enough to promote a Host Header Injection finding — the classical engine has to confirm the behaviour before it reaches the report. See the detection engine
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Host Header Injection
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.