Tenant Routing by Hostname: Subdomains, Customer Domains, and a Safe Resolver
How to map Host headers to tenants without key mismatches, certificate surprises, or a cold-cache stampede, with a lab that tests a common normalizer and singleflight.
Many multi-tenant products give each customer an address of their own: acme.yourapp.io, or a customer domain such as portal.acme.com pointed at your service. Every request then starts with the same question: which tenant does this hostname belong to? The answer decides which data the rest of the request can touch, so the lookup has to be strict about input, fast on the hot path, and safe when the cache is cold.
This guide covers the three routing models, certificates for subdomains and customer domains, a resolver that normalizes the Host header and shares cold-cache lookups, and the security problems specific to hostname routing. The lab runs awkward Host headers through a common normalizer and measures what singleflight does, including a failure mode that most examples miss.
Three ways to put the tenant in the URL
| Model | Example | Certificates | Cookies | Typical use |
|---|---|---|---|---|
| Subdomain per tenant | acme.yourapp.io |
One wildcard certificate for *.yourapp.io |
Can be scoped to one tenant's host | Self-serve products where each workspace has a name |
| Customer domain | portal.acme.com |
One certificate per domain, issued after the customer points DNS at you | Separate site from your own domain | Customers who want their own brand, usually higher plans |
| Path segment | yourapp.io/org/acme/ |
One certificate | Shared by every tenant on the same host | Internal tools, or products where users switch between many organizations |
Path-based routing is the simplest to operate, but every tenant shares one browser origin. A script injected through one tenant's content runs with access to the same cookies and storage as every other tenant the user belongs to. Subdomains give each tenant its own origin, which narrows that blast radius.
Most products that use subdomains also accept customer domains later, so design the lookup around a hostname-to-tenant table from the start rather than parsing a slug out of the subdomain.
Certificates
For your own subdomains, a single wildcard certificate covers every tenant. Let's Encrypt only issues wildcard certificates through the DNS-01 challenge, where your automation publishes a TXT record under _acme-challenge; its challenge types page explains the options and limits. The protocol itself is ACME, RFC 8555.
For customer domains, you cannot use your wildcard, and you cannot complete a DNS challenge in a zone you do not control. The usual flow is: the customer creates a CNAME to a hostname you provide, your system verifies that the record exists, and only then does your edge request a certificate for that exact name, typically with HTTP-01 or TLS-ALPN-01. Request certificates only for domains you have recorded and verified; otherwise anyone who points a domain at you can make you request certificates on their behalf and burn through your issuance rate limits.
You can build this on a proxy that supports on-demand certificates, or use a managed service such as Cloudflare for SaaS, which handles customer hostnames and their certificates at the edge.
Normalizing the Host header
The Host header is client input. Before you use it as a database key, turn it into one canonical form, and reject anything that cannot be a tenant hostname. A canonical form means:
- lowercase;
- no port;
- no trailing dot (
acme.app.io.is a valid, fully qualified way to writeacme.app.io); - internationalized names converted to their ASCII form, so
bücher.app.ioandxn--bcher-kva.app.ioare the same key; - IP literals rejected, because no tenant is registered under an IP address.
The common shortcut, "lowercase it and cut at the first colon", gets several of these wrong. The lab below shows exactly which.
Sharing lookups on a cold cache
After a deployment or cache flush, the first requests for a busy tenant all miss the cache at once. Without coordination, each one queries the tenant table. golang.org/x/sync/singleflight lets concurrent callers for the same key share one in-flight call.
There is a catch. With Group.Do, the function that runs is the first caller's closure, so it uses the first caller's context.Context. If that client disconnects, the shared lookup is cancelled and every caller waiting on it receives the error. The fix is to run the shared lookup on a context detached from any one caller, using context.WithoutCancel plus its own timeout, and let each caller stop waiting on its own context.
Lab: Host normalization and shared lookups
Part 1 runs nine Host values through the shortcut normalizer and through a strict one. Part 2 starts 500 goroutines that miss the cache for the same host together and counts store queries. It then cancels the first caller 5 ms into the 20 ms lookup, once with a shared context and once with a detached one.
The program needs golang.org/x/net and golang.org/x/sync. In an empty directory run go mod init lab, save the program as main.go, then go mod tidy && go run ..
// Lab: host normalization and cold-cache lookups in tenant routing.
//
// Part 1 feeds awkward Host values through a "lowercase and cut at the first
// colon" normalizer and through a stricter one. Part 2 starts 500 goroutines
// that miss the cache for the same host at the same time and counts how many
// reach the tenant store, with and without singleflight, and what happens to
// the waiters when the first caller's request is cancelled.
package main
import (
"context"
"errors"
"fmt"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/idna"
"golang.org/x/sync/singleflight"
)
// naive is the normalizer used by many tutorials.
func naive(host string) string {
host = strings.ToLower(host)
if i := strings.Index(host, ":"); i != -1 {
host = host[:i]
}
return host
}
var profile = idna.New(idna.MapForLookup(), idna.Transitional(false), idna.StrictDomainName(true))
// strict returns the lookup key for a Host header, or an error to answer 400.
func strict(host string) (string, error) {
if host == "" {
return "", errors.New("empty Host")
}
if h, port, err := net.SplitHostPort(host); err == nil {
if port == "" {
return "", errors.New("empty port")
}
host = h
} else if strings.Count(host, ":") > 0 && !strings.HasPrefix(host, "[") {
return "", fmt.Errorf("malformed host:port")
}
host = strings.TrimSuffix(host, ".")
if net.ParseIP(strings.Trim(host, "[]")) != nil {
return "", errors.New("IP literal: not a tenant hostname")
}
ascii, err := profile.ToASCII(host)
if err != nil {
return "", fmt.Errorf("invalid hostname: %v", err)
}
return ascii, nil
}
type store struct{ calls atomic.Int64 }
func (s *store) find(ctx context.Context, host string) (string, error) {
s.calls.Add(1)
select {
case <-time.After(20 * time.Millisecond): // simulated database round trip
return "tenant-for-" + host, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func burst(n int, lookup func(ctx context.Context) error, cancelFirst bool) (failed int64) {
var wg sync.WaitGroup
var fails atomic.Int64
start := make(chan struct{})
for i := 0; i < n; i++ {
ctx, cancel := context.WithCancel(context.Background())
if i == 0 && cancelFirst {
go func() { time.Sleep(5 * time.Millisecond); cancel() }() // first client disconnects
} else {
defer cancel()
}
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
if i != 0 {
time.Sleep(time.Millisecond) // the first caller arrives first and becomes the leader
}
if err := lookup(ctx); err != nil {
fails.Add(1)
}
}(i)
}
close(start)
wg.Wait()
return fails.Load()
}
func main() {
fmt.Println("Part 1: Host header normalization")
fmt.Printf("%-28s %-26s %s\n", "Host header", "lowercase+cut at ':'", "strict normalizer")
for _, h := range []string{
"Acme.App.io", "acme.app.io.", "acme.app.io:8443", "[2001:db8::1]:443",
"BÜCHER.app.io", "xn--bcher-kva.app.io", "acme.app.io:", "acme.app.io:80:80", "",
} {
got, err := strict(h)
s := got
if err != nil {
s = "reject: " + err.Error()
}
fmt.Printf("%-28q %-26q %s\n", h, naive(h), s)
}
fmt.Println("\nPart 2: 500 concurrent cold-cache lookups for one host (store latency 20 ms)")
var plain store
burst(500, func(ctx context.Context) error { _, err := plain.find(ctx, "acme.app.io"); return err }, false)
fmt.Printf("without singleflight: store queries = %d\n", plain.calls.Load())
var sf store
var g singleflight.Group
burst(500, func(ctx context.Context) error {
_, err, _ := g.Do("acme.app.io", func() (any, error) { return sf.find(ctx, "acme.app.io") })
return err
}, false)
fmt.Printf("with singleflight: store queries = %d\n", sf.calls.Load())
var sf2 store
var g2 singleflight.Group
failed := burst(500, func(ctx context.Context) error {
_, err, _ := g2.Do("acme.app.io", func() (any, error) { return sf2.find(ctx, "acme.app.io") })
return err
}, true)
fmt.Printf("leader cancelled, shared ctx: %d of 500 lookups failed, store queries = %d\n", failed, sf2.calls.Load())
var sf3 store
var g3 singleflight.Group
failed = burst(500, func(ctx context.Context) error {
ch := g3.DoChan("acme.app.io", func() (any, error) {
lctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
return sf3.find(lctx, "acme.app.io")
})
select {
case r := <-ch:
return r.Err
case <-ctx.Done():
return ctx.Err() // only this caller gives up
}
}, true)
fmt.Printf("leader cancelled, detached ctx: %d of 500 lookups failed, store queries = %d\n", failed, sf3.calls.Load())
}
Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64, golang.org/x/net v0.59.0, golang.org/x/sync v0.24.0):
Part 1: Host header normalization
Host header lowercase+cut at ':' strict normalizer
"Acme.App.io" "acme.app.io" acme.app.io
"acme.app.io." "acme.app.io." acme.app.io
"acme.app.io:8443" "acme.app.io" acme.app.io
"[2001:db8::1]:443" "[2001" reject: IP literal: not a tenant hostname
"BÜCHER.app.io" "bücher.app.io" xn--bcher-kva.app.io
"xn--bcher-kva.app.io" "xn--bcher-kva.app.io" xn--bcher-kva.app.io
"acme.app.io:" "acme.app.io" reject: empty port
"acme.app.io:80:80" "acme.app.io" reject: malformed host:port
"" "" reject: empty Host
Part 2: 500 concurrent cold-cache lookups for one host (store latency 20 ms)
without singleflight: store queries = 500
with singleflight: store queries = 1
leader cancelled, shared ctx: 500 of 500 lookups failed, store queries = 1
leader cancelled, detached ctx: 1 of 500 lookups failed, store queries = 1
What the output shows
- IPv6 literals break the shortcut.
[2001:db8::1]:443became"[2001", a key that is meaningless but will still be looked up and possibly cached. - A trailing dot creates a second key for the same tenant. Depending on how your cache and allow-lists work, that can mean a cache miss on every such request or a hostname that bypasses a check.
- Unicode and punycode spellings stay different under the shortcut, so
BÜCHER.app.iowould not find a tenant registered asxn--bcher-kva.app.io. - Malformed ports are silently accepted.
acme.app.io:80:80was treated asacme.app.io. - singleflight reduced 500 queries to one. That is the point of using it.
- With a shared context, cancelling one client failed all 500 lookups. On a busy tenant after a deploy, one impatient browser can return errors to everyone who arrived in the same window.
- With a detached context, only the cancelled caller failed. The other 499 received the tenant.
A resolver you can adapt
The package below combines both fixes, caches results for a fixed time, caches "not found" for a shorter time so unknown hostnames do not hit the database on every request, and does not cache errors. It compiles with Go 1.26 and passes a table test covering the cases in the lab. It has not been load-tested; measure it with your own store before use.
// Package tenantroute resolves the tenant for an incoming request from its
// Host header and stores it in the request context.
package tenantroute
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/net/idna"
"golang.org/x/sync/singleflight"
)
type Tenant struct {
ID string
Status string // "active" or "suspended"
}
// ErrNotFound is returned by a Store when no tenant owns the hostname.
var ErrNotFound = errors.New("tenant not found")
type Store interface {
FindByHostname(ctx context.Context, host string) (*Tenant, error)
}
type ctxKey struct{}
// FromContext returns the tenant set by the middleware, if any.
func FromContext(ctx context.Context) (*Tenant, bool) {
t, ok := ctx.Value(ctxKey{}).(*Tenant)
return t, ok
}
var profile = idna.New(idna.MapForLookup(), idna.Transitional(false), idna.StrictDomainName(true))
// Normalize turns a Host header into a lookup key: lowercase ASCII (punycode),
// no port, no trailing dot. IP literals and malformed values are rejected.
func Normalize(host string) (string, error) {
if host == "" {
return "", errors.New("empty Host header")
}
if h, port, err := net.SplitHostPort(host); err == nil {
if port == "" {
return "", errors.New("empty port")
}
host = h
} else if strings.Contains(host, ":") {
return "", errors.New("malformed host:port")
}
host = strings.TrimSuffix(host, ".")
if net.ParseIP(strings.Trim(host, "[]")) != nil {
return "", errors.New("IP literal is not a tenant hostname")
}
return profile.ToASCII(host)
}
type entry struct {
tenant *Tenant // nil records a confirmed "not found"
expires time.Time
}
type Resolver struct {
store Store
ttl, negTTL time.Duration
lookupLimit time.Duration
group singleflight.Group
mu sync.RWMutex
cache map[string]entry
}
func NewResolver(s Store, ttl, negTTL, lookupLimit time.Duration) *Resolver {
return &Resolver{store: s, ttl: ttl, negTTL: negTTL, lookupLimit: lookupLimit, cache: map[string]entry{}}
}
// Resolve returns the tenant for a normalized hostname. Concurrent misses
// for the same host share one store query. The shared query runs on a
// context detached from any single caller, so one client disconnecting
// does not fail everyone waiting on the same lookup.
func (r *Resolver) Resolve(ctx context.Context, host string) (*Tenant, error) {
r.mu.RLock()
e, ok := r.cache[host]
r.mu.RUnlock()
if ok && time.Now().Before(e.expires) {
if e.tenant == nil {
return nil, ErrNotFound
}
return e.tenant, nil
}
ch := r.group.DoChan(host, func() (any, error) {
lctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.lookupLimit)
defer cancel()
t, err := r.store.FindByHostname(lctx, host)
switch {
case errors.Is(err, ErrNotFound):
r.put(host, entry{nil, time.Now().Add(r.negTTL)})
case err == nil:
r.put(host, entry{t, time.Now().Add(r.ttl)})
}
return t, err // other errors are not cached
})
select {
case res := <-ch:
if res.Err != nil {
return nil, res.Err
}
return res.Val.(*Tenant), nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (r *Resolver) put(host string, e entry) {
r.mu.Lock()
r.cache[host] = e
r.mu.Unlock()
}
// Invalidate removes a hostname after a domain is added, moved, or deleted.
func (r *Resolver) Invalidate(host string) {
r.mu.Lock()
delete(r.cache, host)
r.mu.Unlock()
}
// Middleware resolves the tenant before any authentication or database work.
// Requests for hosts in skip (for example the marketing site) pass through.
func (r *Resolver) Middleware(skip map[string]bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
host, err := Normalize(req.Host)
if err != nil {
http.Error(w, "bad host", http.StatusBadRequest)
return
}
if skip[host] {
next.ServeHTTP(w, req)
return
}
t, err := r.Resolve(req.Context(), host)
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "workspace not found", http.StatusNotFound)
return
case err != nil:
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
case t.Status != "active":
http.Error(w, "workspace unavailable", http.StatusForbidden)
return
}
ctx := context.WithValue(req.Context(), ctxKey{}, t)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
// String helps when logging a tenant without exposing more fields.
func (t *Tenant) String() string { return fmt.Sprintf("tenant(%s)", t.ID) }
Run the resolver before authentication and before opening a database transaction, so later layers can rely on FromContext. If your sessions are tied to a tenant, check that the session's tenant matches the resolved tenant on every request.
Security problems specific to hostname routing
| Problem | What happens | What to do |
|---|---|---|
| Host header injection | Code builds absolute URLs, such as password-reset links, from the Host header, and an attacker supplies their own host | Build links from the tenant's stored hostname, never from the request. PortSwigger's Host header attacks material walks through the variants |
| Dangling customer domains | A customer leaves but their CNAME still points at you; someone else claims that hostname on your platform | Re-verify DNS before attaching a hostname to a new tenant, and keep removed hostnames reserved for a period you choose and document |
| Cookies shared across tenants | A session cookie set with Domain=yourapp.io is sent to every tenant subdomain |
Omit the Domain attribute so the cookie is host-only, as defined in RFC 6265 |
| Spoofed tenant headers | An internal header such as X-Tenant-ID is set by the edge and trusted by services |
Strip client-supplied copies at the edge and have services derive the tenant from authenticated context |
| Reserved names | A customer registers admin, api, status, or mail as a workspace name |
Keep a reserved-name list and check it at signup, including look-alike spellings |
Where to start
Collect a day of Host headers from your access logs and run them through your current normalization. Count how many distinct keys map to the same tenant and how many values contain ports, trailing dots, or non-ASCII characters. Then check the context your shared lookup runs on. If it is the first caller's, a single cancelled request can fail a crowd, and the fix is a few lines. The tenant isolation guide covers what happens after the tenant is known, and the rate-limiting guide explains how to use the resolved tenant as the key for per-tenant limits.
Frequently Asked Questions
Should the tenant come from the subdomain slug or from a lookup table?
Use a table keyed by full hostname from the start. It handles customer domains, renamed workspaces, and reserved names with the same code, and a slug-parsing shortcut becomes a second path to maintain once you add custom domains.
How long should resolved tenants stay in the cache?
Long enough that the store is not queried on every request, short enough that a suspended tenant or moved domain takes effect within the time you promise. Also invalidate entries explicitly when a domain is added, removed, or the tenant's status changes.
Why cache 'not found' results?
Scanners and misconfigured clients request random hostnames. Without a negative cache, each one becomes a database query. Use a shorter time for negative entries so a newly added domain starts working quickly.
Is a wildcard DNS record a security risk?
It means every name under your domain resolves to your edge, so the application must decide which names are real. That is fine as long as unknown hostnames get a 404 and are never treated as a default tenant.
Can I trust X-Forwarded-Host instead of Host?
Only if your own proxy sets it and strips any value the client sent. Configure your framework to trust forwarding headers only from known proxy addresses, and prefer the header your edge actually controls.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
Challenge Types
(Let's Encrypt)
• Link checked 2026-11-03
HTTP-01, DNS-01, and TLS-ALPN-01, including which can issue wildcard certificates.
-
RFC 8555: Automatic Certificate Management Environment (ACME)
(Internet Engineering Task Force)
• Link checked 2026-11-03
The certificate issuance protocol, including wildcard identifiers.
-
Cloudflare for SaaS
(Cloudflare)
• Link checked 2026-11-03
A managed option for customer hostnames and certificates.
-
singleflight package
(The Go Authors)
• Link checked 2026-11-03
Do and DoChan semantics for sharing in-flight calls.
-
context package: WithoutCancel
(The Go Authors)
• Link checked 2026-11-03
Deriving a context that keeps values but is not cancelled with its parent.
-
HTTP Host header attacks
(PortSwigger Web Security Academy)
• Link checked 2026-11-03
How trusting the Host header leads to password-reset poisoning and routing attacks.
-
RFC 6265: HTTP State Management Mechanism
(Internet Engineering Task Force)
• Link checked 2026-11-03
Host-only cookies when the Domain attribute is omitted.
Revision history
- — Added a reproducible lab and a tested resolver. Replaced the previous normalizer, which mishandled IPv6 hosts, trailing dots, internationalized names, and malformed ports, and documented the shared-context failure in singleflight. Removed the fixed 90-day cooldown and the claim that one wildcard certificate removes all issuance delay. Corrected source labels.
- — Removed a universal cache TTL and corrected certificate wording.