Applications that build absolute URLs, redirects or routing decisions from the HTTP Host header trust a value the client chooses, and that one assumption enables password reset poisoning, web cache poisoning and access to internal virtual hosts. The Host header tells a server which site the client wants, and the de facto X-Forwarded-Host header carries the original value through proxies and CDNs. Both are attacker-controlled unless something in front of the application validates them. This guide explains how host header attacks work at a defensive level, where they show up, how to detect them, and how to enforce an allowlist in Django, Rails, ASP.NET Core and at the proxy. It is for web developers, platform engineers and application security testers.
Key takeaways
- The Host header is user input. Treat it, and every
X-Forwarded-*header, like a query parameter: never use it to build security-relevant URLs without validation. - Allowlist at two layers. Configure the reverse proxy to reject unknown hosts with a default catch-all server, and enable the framework allowlist (
ALLOWED_HOSTS,config.hosts,AllowedHosts) in the application. - Hard-code the base URL for emails. Password reset, invitation and magic links should use a configured canonical origin, which the OWASP Forgot Password Cheat Sheet recommends directly.
- Trust forwarding headers only from your own proxies. Enable
X-Forwarded-Hosthandling only behind a proxy that overwrites it, and strip client-supplied copies at the edge. - Rails ships with no production host check by default. Rails documents that outside development
config.hostsis empty and no Host header checks are done, so production apps must opt in.
How the attack works
Under HTTP/1.1, a server picks the virtual host from the Host header. RFC 9112 Section 3.2 requires a client to send Host in every HTTP/1.1 request and requires a server to answer 400 (Bad Request) to any request that lacks a Host header, contains more than one Host header field line, or has an invalid Host value. When a request uses an absolute-form target (the full URL in the request line), the origin server must ignore the received Host header and use the host from the request target instead.
The weakness is not in the protocol but in what applications do with the value. The OWASP Web Security Testing Guide (WSTG-INPV-17) lists four outcomes when the Host header is not validated: the request is dispatched to the first virtual host on the server, the application redirects to an attacker-controlled domain, the response is poisoned in a web cache, or password reset functionality is manipulated. WSTG also notes that where an application checks Host, the same value supplied in X-Forwarded-Host may bypass the check, because many frameworks and proxies prefer the forwarded header when it is present.
Several features read the host implicitly:
- Absolute URL helpers:
request.build_absolute_uri()in Django,url_for(..., _external=True)in Flask,*_urlhelpers in Rails, and equivalent functions elsewhere, all derive the scheme and host from the request unless configured otherwise. - Redirects: responses that redirect to "the same host" by echoing the header.
- Multi-tenant routing: SaaS platforms that map the host to a tenant.
- Framework security features: Django's own documentation says a fake Host value can be used for cross-site request forgery, cache poisoning and poisoning links in emails.
Where it shows up
| Scenario | What goes wrong | Primary fix |
|---|---|---|
| Password reset or account recovery emails | The reset link carries a secret token but points at the attacker's domain; if the victim clicks it, the token is disclosed | Build links from a configured canonical base URL; see the Secure Password Reset Flow Guide |
| Invitation, verification and magic link emails | Same mechanism as reset poisoning, often with longer-lived tokens | Same: canonical base URL and short token lifetimes |
| Shared caches and CDNs | A response built with a poisoned host (script URLs, canonical links, redirects) is cached and served to everyone | Validate host before the cache key is computed; include host in the cache key; see the Web Cache Poisoning and Cache Deception Guide |
| Default virtual host | An unknown host lands on the first configured site, which may be an admin console or staging app | Explicit catch-all server that rejects unknown hosts |
| Proxies routing on Host | A crafted host value makes a proxy route to an internal service | Route only on an allowlist; see the Proxies and Forward vs Reverse Proxy Security Guide |
| DNS rebinding against local or internal services | A malicious page resolves its own name to an internal IP; the request arrives with a foreign Host | Host allowlist; Rails documents config.hosts as a defence against DNS rebinding |
| Redirects and canonical links | Echoed host produces an open redirect or a wrong canonical URL | Use configured origin; see the Open Redirect Guide |
Impact
- Account takeover: reset poisoning hands the attacker a valid reset token for a target account, which is why it rates as high severity in most bug bounty programmes. The attacker needs only the victim's email address and one click from the victim (or an email security scanner that fetches links).
- Mass compromise through caching: a single poisoned cache entry can load attacker script for every visitor until the entry expires.
- Exposure of internal applications: default virtual hosts and proxy routing can expose administration or staging sites that were never meant to be internet-reachable.
- Trust and phishing: emails from your real domain containing links to an attacker's domain are highly convincing.
Detection
Host header abuse is visible in logs if you record the right fields. Make sure access logs capture the raw Host header, X-Forwarded-Host, Forwarded and the server name that matched.
| Signal | Source | Interpretation |
|---|---|---|
Django DisallowedHost errors (logged by the django.security.DisallowedHost logger) | Application logs | Requests rejected by ALLOWED_HOSTS; background noise from scanners is normal, a spike on reset endpoints is not |
| Rails HostAuthorization 403 responses | Application logs | Blocked hosts; check for health checks wrongly blocked after rollout |
| Hits on the catch-all server block | Proxy or load balancer logs | Unknown hosts reaching your IP addresses |
X-Forwarded-Host present on requests arriving from the internet | Edge logs | Clients supplying forwarding headers that should only come from your proxies |
| Password reset requests with non-canonical host values | Application logs on reset endpoints | Likely poisoning attempts; correlate with the target account |
| Outbound emails containing links to unexpected domains | Mail gateway or template tests | A template still uses request-derived URLs |
For testing, WSTG-INPV-17 describes the checks a tester should run: supply an unrecognised domain in the Host header, repeat with X-Forwarded-Host, and observe redirects, generated links and cached responses. Run these in authorised tests and in CI against staging, including on every endpoint that sends email.
Metrics
- Coverage: percentage of internet-facing applications with an explicit host allowlist in the framework and at the proxy (target 100%).
- Email link integrity: number of email templates building URLs from the request rather than configuration (target zero).
- Edge hygiene: share of requests where client-supplied
X-Forwarded-Hostreaches the application (target zero). - Finding recurrence: host header findings per penetration test cycle, trending down.
Prevention and hardening
Framework allowlists
| Framework | Setting | Default and behaviour |
|---|---|---|
| Django | ALLOWED_HOSTS | Default empty list. Exact names match case-insensitively without port; a leading dot (.example.com) matches the domain and subdomains; '*' matches anything and makes you responsible for validation. A mismatch makes HttpRequest.get_host() raise SuspiciousOperation. With DEBUG = True and an empty list, Django validates against .localhost, 127.0.0.1 and [::1]. |
| Django | USE_X_FORWARDED_HOST | Default False. Django says enable it only if a proxy which sets the header is in use. |
| Rails | config.hosts (HostAuthorization middleware) | Populated for development; empty in other environments, so no checks are done until you add hosts. Entries can be strings, anchored regular expressions, IPAddr or a leading-dot domain. Blocked requests get 403 by default, customisable with config.host_authorization.response_app; exclusions with config.host_authorization.exclude. |
| ASP.NET Core | AllowedHosts (host filtering middleware) | Microsoft documents that Kestrel does not validate Host headers and that host filtering is disabled until AllowedHosts is defined in appsettings.json as a semicolon-delimited list. Forwarded headers middleware has its own AllowedHosts option for proxied deployments. |
One Django detail matters in code review: validation only happens through get_host(). Django's documentation warns that code reading the header directly from request.META bypasses the protection. Search for HTTP_HOST and HTTP_X_FORWARDED_HOST in application code. See the Django Security Guide and the Ruby on Rails Security Guide for the wider framework settings.
Implementation steps
- Inventory host usage: grep for absolute URL helpers, direct header reads and email templates. List every hostname each application legitimately serves, including health check and internal names.
- Set a canonical origin: add a configuration value (for example
SITE_URLorconfig.action_mailer.default_url_optionsin Rails) and switch every email and redirect builder to it. - Harden the edge: on the reverse proxy, add a default server that rejects requests whose host is not configured, and have the proxy overwrite (not append to)
X-Forwarded-HostandForwarded. ASVS 5.0 requirement V4.1.3 requires that headers set by an intermediary cannot be overridden by the end user. - Enable the framework allowlist in report mode where possible: log rejections in staging and a canary first. Health checks and load balancer probes that use IP addresses are the usual breakage; allow them explicitly or exclude the health path.
- Enforce in production: roll out per application, watching rejection rates. Roll back by reverting the allowlist change, not by setting a wildcard.
- Fix the cache: ensure the CDN or cache validates host before caching and includes it in the cache key, and that unkeyed forwarding headers are stripped. See the CDN Security Guide.
- Add regression tests: unit tests that send a foreign Host and
X-Forwarded-Hostto the reset endpoint and assert a rejection and an unchanged email link.
Decision guidance
- Single-domain applications: exact host entries only, no wildcards, forwarded headers off unless a proxy sets them.
- Multi-tenant SaaS with customer subdomains: a leading-dot or anchored regex entry for your platform domain, plus a database lookup for custom domains in a first-position middleware. Never use
'*'without that middleware. - Behind a CDN or load balancer: validate at both layers. The edge protects caches and routing; the application protects itself if someone reaches the origin directly.
Where identity fits
Host header poisoning is primarily an attack on identity workflows. The token in a reset, invitation or email verification link is a bearer credential: whoever holds it can set a password or bind an account. Treat these flows as part of your authentication system, with short-lived single-use tokens, notifications on change and re-authentication for sensitive account changes. See the Account Takeover Prevention Guide and the Magic Links and Email OTP Security Guide.
Common failure patterns
- Wildcard allowlists:
ALLOWED_HOSTS = ['*']added to silence errors behind a load balancer, with no replacement validation. - Trusting forwarded headers from anyone: enabling
USE_X_FORWARDED_HOSTor a framework "trust proxy" setting while the edge passes client-supplied values through. - Rails production without
config.hosts: assuming the development protection applies everywhere. - Request-derived URLs in email: using the absolute URL helper inside a mailer that runs in the request context.
- Unanchored or loose patterns: custom regex checks such as "ends with example.com" that also match
evil-example.com. - Health checks forcing exceptions: blanket bypasses added for probes instead of a narrow path exclusion.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| OWASP WSTG | WSTG-INPV-17 Testing for Host Header Injection | Test method |
| OWASP ASVS 5.0 | V4.1.3 | Intermediary-set headers cannot be overridden by the end user |
| NIST SP 800-53 Rev. 5 | SI-10 Information Input Validation; CM-6 Configuration Settings; AU-6 Audit Record Review, Analysis, and Reporting | Validate the header, configure allowlists, review rejection logs |
| NIST CSF 2.0 | PR.PS-01, DE.CM-09 | Configuration management; monitoring of runtime environments |
| MITRE CAPEC | CAPEC-141 Cache Poisoning | Cache poisoning outcome |
Practitioner checklist
- Configure an explicit host allowlist in every framework (
ALLOWED_HOSTS,config.hosts,AllowedHosts); no bare wildcards. - Add a default catch-all server at the proxy that rejects unknown hosts.
- Overwrite
X-Forwarded-HostandForwardedat the edge; enable forwarded-host trust only behind that proxy. - Build every email, reset and redirect URL from a configured canonical origin.
- Remove direct reads of
HTTP_HOSTand forwarded headers from application code. - Make the cache validate and key on host, and strip unkeyed forwarding headers.
- Log raw Host and forwarded headers, and alert on rejection spikes at reset and login endpoints.
- Add regression tests that send foreign hosts to email-sending endpoints.
Standards and references
- IETF: RFC 9112 HTTP/1.1
- OWASP: WSTG Testing for Host Header Injection
- OWASP: Forgot Password Cheat Sheet
- Django: Security in Django (Host header validation)
- Django: Settings reference (ALLOWED_HOSTS, USE_X_FORWARDED_HOST)
- Ruby on Rails Guides: Configuring Rails Applications (HostAuthorization)
- Microsoft Learn: Host filtering with ASP.NET Core Kestrel
- MDN: X-Forwarded-Host header
Related NHI Mgmt Group resources: Secure Password Reset Flow Guide · Web Cache Poisoning and Cache Deception Guide · Django Security Guide · HTTP Request Smuggling Guide · Account Takeover Prevention Guide