Custom domains and a Host you can't trust
- #security
- #cloudflare
- #multi-tenancy
Every multi-tenant product eventually gets the same request: can our help
center live on our domain instead of yours? It is a reasonable ask. A support
site at help.customer.com inherits the customer's brand and their SEO,
instead of donating both to a vendor subdomain.
The feature sounds like a certificate problem. Issue TLS for a hostname you do not own, route it to the right tenant, done. Certificates turn out to be the easy half, because a managed service will do them for you.
The hard half is that solving the routing forces you to rewrite the Host
header, and Host is the field your entire tenant lookup is built on. The
moment an intermediary rewrites it, "which tenant is this?" stops being a
parsing question and becomes an authentication question. Most of the
interesting work is in that sentence.
The easy half
Cloudflare for SaaS handles the certificate story. The customer points
help.customer.com at a hostname we own with a CNAME. We register that
hostname with Cloudflare over its API, and get back the DNS records the
customer needs to publish: the CNAME itself, a TXT record proving they control
the domain, and a TXT record for the ACME challenge that issues the
certificate.
add_domain(workspace, hostname):
validate(hostname) # bare host, not a reserved apex
result = edge_api.create_custom_hostname(hostname)
store(workspace, hostname, state="verifying", records=result.records)
return result.records # shown to the customer to publish
check_status(workspace):
state = edge_api.get_custom_hostname(stored_id)
# "verifying" until DNS propagates, then "active", or "failed" with errors
store(workspace, state=state.state, errors=state.errors)The customer publishes the records, the edge notices, a certificate is issued, and the hostname flips to active. There is a status endpoint to poll and a list of errors to show when validation fails, which is most of the UI work.
None of that is the interesting part. The interesting part starts one layer down, at the origin.
The collision
Cloudflare for SaaS sends the customer's hostname to the origin, as both
the Host header and the TLS SNI. From the origin's point of view, a request
arrives claiming to be for help.customer.com.
Our application host does host-based routing. It matches the incoming Host
against the domains registered on the service, and if it does not recognize
one, the request never reaches our application at all. It gets a 404 from the
platform's router, with a header saying it fell through.
So the request dies one hop before our code.
The obvious fix is to register each customer domain with the platform. That does not work here, for two reasons that compound:
- The application has no API integration with the hosting platform, so it cannot self-register a hostname when a customer adds one. Every new custom domain would be a manual operations step, which defeats self-service.
- Even if it could, the platform verifies ownership by checking that the domain points at it. Our customer domains point at Cloudflare, because that is what terminates their TLS. The verification can never pass.
The second reason is the real wall. It is not that automation is missing; it is that two systems both want to be the thing the customer's DNS points at, and only one of them can be.
Lying to the router
The way out is to stop sending the platform a Host it will reject, and start
sending it one it owns.
Cloudflare can rewrite the Host header on the way to the origin. So the
request that arrives at our application says it is for
domains.acornreply.com, a hostname the platform recognizes and routes
happily. The real customer hostname travels alongside it in a separate header.
on request for help.customer.com:
terminate TLS with the customer's certificate
set header x-acorn-forwarded-host = "help.customer.com" # the truth
set header x-acorn-edge-secret = <shared secret>
rewrite Host = "domains.acornreply.com" # the lie
forward to originThis works. It is also the moment the feature becomes a security feature, because we have just built a system where the application's idea of "which site am I serving?" comes from a header instead of from the connection.
Why the header cannot be trusted on its own
The fallback origin is a real, publicly reachable hostname. Anyone can send it a request directly, skipping Cloudflare entirely. And anyone can set an arbitrary header on a request they construct.
So if the application simply read x-acorn-forwarded-host and served whatever
tenant it named, then this would be a complete tenant-impersonation exploit:
curl https://domains.acornreply.com/knowledge \
-H "x-acorn-forwarded-host: help.some-other-customer.com"There is no cleverness in that attack. That is the point. Header-based tenant selection with a directly-reachable origin is not a subtle bug, it is an open door, and it is easy to build by accident because the header works perfectly in testing.
The fix is to authenticate the rewrite. Cloudflare injects a shared secret alongside the forwarded host, and the origin trusts the forwarded host only when that secret matches. A request that arrives without the secret is not a custom-domain request, whatever its headers claim, and falls through to normal host-based handling.
handle(request):
# FIRST, before any host classification
if edge_secret_configured and request.header("x-acorn-edge-secret") == edge_secret:
forwarded = normalize(request.header("x-acorn-forwarded-host"))
if forwarded:
tenant = lookup_tenant_by_hostname(forwarded)
if tenant:
return serve_tenant(tenant, public_origin = "https://" + forwarded)
return 404 # authenticated, but not one of our surfaces
# otherwise: ordinary Host-based routing (apex, tenant subdomain, www, ...)
...Three details in that block earn their place.
It runs first. The rewritten Host is domains.acornreply.com, which
under our normal parsing rules looks exactly like a tenant subdomain of our
own apex. If the custom-domain branch ran after host classification, a
legitimate custom-domain request would already have been misrouted to a
nonexistent tenant named domains. The ordering is not stylistic; it is the
difference between the feature working and not.
An authenticated request for an unknown host is a 404, not a fallthrough. If the secret is valid but the hostname does not map to an active custom domain, the request came through our edge for a domain we are not serving. The tempting behavior is to fall through and render the marketing site. The correct behavior is to 404, because falling through means our marketing homepage starts appearing on random hostnames, which is both an SEO problem and a confusing one to debug.
The secret being unset disables the branch entirely. In development and tests there is no edge in front of the app, so the whole path is inert rather than half-enabled. That is what makes it safe for the check to be first: with no secret configured, it cannot fire at all.
The general rule: a forwarded-host header is only as trustworthy as your origin is unreachable. If an attacker can talk to your origin directly, the header is attacker-controlled input, and any security decision that reads it needs proof the request actually came through your edge.
Defense in depth: the headers stop at the door
One more thing, which is cheap and worth doing.
Once the proxy has used the edge headers, it deletes them before the request
reaches application code. Route handlers and server components never see
x-acorn-forwarded-host or x-acorn-edge-secret.
This is not because anything downstream currently reads them. It is because the trust decision has already been made, correctly, in one place, and leaving the raw inputs lying around invites a future handler to read the forwarded host directly and reintroduce the vulnerability without the secret check. The headers are proxy-internal plumbing. Stripping them makes that structural rather than a convention someone has to remember.
The same stripping happens on the 404 path, not just the success path, because "the request failed" is exactly the sort of branch where cleanup gets forgotten.
The payoff nobody asks for but everybody wants
There is a quiet benefit to routing this way that is worth naming.
Once a workspace has an active custom domain, that domain becomes the canonical public origin for its help center. Everything that generates a public URL, sitemaps, canonical tags, structured data, links inside articles, resolves against it rather than against the vendor subdomain.
Which means the workspace's slug stops being load-bearing for SEO. Before a custom domain, renaming a workspace changes every public URL it has, and a rename is an SEO event. After, the slug is an internal identifier and the customer can rename freely. Customers do not ask for this. They notice it when they would otherwise have been burned.
Where this is heading
Two shifts seem likely.
The first is that "bring your own domain" keeps sliding from a differentiator
to an expectation, including on smaller plans. The certificate half is already
a commodity, and the routing half is the part that stays awkward, because it
depends on the specific combination of edge provider and hosting platform you
happen to use. The rewrite-and-authenticate pattern in this post exists purely
because two layers disagreed about what Host means. Platforms that expose a
first-class "this request arrived for hostname X, on behalf of tenant Y"
concept, rather than making everyone smuggle it through headers, would remove
an entire class of self-inflicted vulnerability.
The second is that the trust boundary itself gets better primitives. A shared static secret in a header is the simplest thing that works, and it has the weaknesses you would expect: it is long-lived, it is the same for every request, and rotating it means coordinating an edge rule with an origin deploy. Signed, short-lived, per-request proofs of edge origin, in the shape of mutual TLS between edge and origin, or a signed token bound to the forwarded host, are strictly better and increasingly available. We would use one; the static secret is where we started, not where this should end.
Closer to home, the piece we would most like to remove is the fallback origin's direct reachability. The entire authentication dance exists because that hostname answers to anyone. An origin that only accepts connections authenticated as coming from our edge would make the forwarded host trustworthy by construction, and turn a security decision back into a parsing one.
Further reading
- Cloudflare for SaaS — custom hostnames, ownership validation, and certificate issuance for domains you do not control.
- Cloudflare Origin Rules
— rewriting the
Hostheader and origin destination on the way to your server. - Cloudflare Transform Rules: request header modification — how the forwarded host and the shared secret get attached at the edge.
- MDN:
X-Forwarded-Host— the conventional version of this header, and the standard warnings about trusting it. - PortSwigger: Host header attacks — what goes wrong when routing or security decisions read an attacker-controlled host.
- RFC 9110: HTTP Semantics — the
Hostheader's actual contract, which is narrower than most applications assume.