Safeguard
Application Security

Your Application's Hostname Should Not Be an Input

To build a reset link your app needs to know its own hostname, and the convenient place to find it is the Host header. That header is supplied by the client, so a stranger decides what goes in the email you send.

Tomas Lindgren
Platform Engineer
5 min read

Your application builds absolute URLs: the link in a password reset email, the callback in an OAuth flow, the canonical tag on a page. To do that it needs to know its own hostname, and the convenient place to find that is the Host header of the incoming request.

That header is supplied by the client. So an attacker who can send a request to your server can decide what hostname your application thinks it has, and therefore what hostname appears in the links it generates.

This post is what that enables and how to stop deriving your identity from a request. For whoever wrote the code that constructs a reset link.

Password reset poisoning

The clearest consequence, and the one worth leading with.

An attacker submits a password reset for someone else's account, with a forged Host header pointing at their own domain. Your application generates a token, builds a link from the request's host, and emails it to the victim. The email is genuine, from your domain, with your branding, and the link points somewhere the attacker controls.

If the victim clicks, the token is delivered to the attacker. Even if they do not click, some mail clients and security scanners fetch links automatically, which hands the token over without any human action at all.

The same shape applies to invitation links, email verification, magic links and any callback constructed from the request.

Cache poisoning

If a response is cached and the cache key does not include the host, but the response body does, one poisoned request contaminates the cached copy served to everyone.

Anything reflected into the page from the host becomes a stored injection: an absolute URL to a script, a canonical link, an asset base path. A single request can change what every subsequent visitor loads.

Routing and validation confusion

Multiple layers parse Host and they do not always agree: a load balancer, a proxy, an application server and a framework. Where one accepts a value another rejects, or where one uses Host and another uses X-Forwarded-Host, you get a routing decision made on one value and a security decision made on another.

This is the same family as the forwarded-address problem: a value that arrives from outside, is rewritten by some hops and not others, and is trusted differently by each layer.

The fix is configuration, not validation

Do not derive your hostname from the request. Configure it.

# not this
reset_url = f"https://{request.headers['host']}/reset?token={token}"

# this
reset_url = f"{settings.CANONICAL_BASE_URL}/reset?token={token}"

One constant per environment, used everywhere a link is constructed. It removes the whole class, it is simpler than validating, and it is correct even when a request arrives through an unexpected path.

Then, separately, reject unexpected hosts at the edge. An allowlist of hostnames you serve, enforced by the load balancer or the framework's equivalent, so a request for an unknown host is refused rather than processed. Most frameworks have a setting for this and it is frequently left permissive in development and never tightened.

Also check X-Forwarded-Host, which several frameworks honour in preference to Host when present, and which is exactly as attacker-controlled.

Multi-tenant hostnames need care

If you serve customer subdomains or custom domains, you cannot use a single constant, and the host genuinely selects the tenant.

Two rules make that safe. Resolve the host to a tenant through a lookup of known hostnames, so an unknown value is a rejection rather than a new tenant. And build links from the resolved tenant's canonical hostname rather than from the header, so even a valid-looking host that resolved to a tenant does not decide what goes in the email.

Check yours

# Does the app reflect a forged host into a generated link?
curl -s -X POST https://app.example.com/password-reset \
  -H 'Host: attacker.example' \
  -d 'email=victim@example.com' -o /dev/null -w "%{http_code}\n"
# then read the email that arrives: whose domain is in the link?

# Is an unknown host rejected at the edge?
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/ -H 'Host: nope.example'
# want 400 or 404, not 200

# Is X-Forwarded-Host honoured?
curl -s https://app.example.com/ -H 'X-Forwarded-Host: attacker.example' | grep -c attacker.example

The first test requires a mailbox you control and it is the one that produces an unambiguous answer. Reading the resulting email is the whole check.

The concession

Deriving the host from the request is not laziness, it is what makes an application work unchanged across environments: local, staging, preview deployments, and production, without a configuration change per deployment. Preview environments with generated hostnames make a single constant genuinely awkward.

The workable version is to configure it per environment and accept that preview deployments need the value injected at deploy time, which the platform generating the hostname can do. That is a small amount of plumbing, and it is bounded, whereas trusting the header is a standing exposure in every environment including production.

The implication

Your application's identity should not be an input. Everywhere it constructs a link, a redirect, a callback or a canonical URL, the hostname should come from configuration you control rather than from a header a stranger set.

The test is one request and one email. Send yourself a reset with a forged host and read what arrives.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.