Skip to content
gohacks.dev
Web

Host Based Routing and Multi Tenant Servers in Go Apps

Resolve the tenant from the Host header in a Go server, with subdomains and custom domains, safe fallbacks, a context value and rules for unknown hosts.

7 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

The first multi tenant Go server I would trust has no clever routing in it at all. It has one small piece of middleware that answers a single question, which tenant is this request for, and every handler after it consumes the answer. All the hard decisions about isolation live in that question, so it is worth getting right before any tenant data is stored.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds the resolver for the usual two shapes of tenant address, a subdomain you control (acme.example.test) and a custom domain the customer owns (shop.acme.com), and runs it against requests with awkward hosts. It belongs to the web cluster that ends in a production HTTP server, follows from middleware basics, and the standard mux can handle fixed hosts as covered in subdomain routing. Everything ran on Go 1.25.6.

Why not just use the mux

http.ServeMux can route on a host, but only a literal one: "api.example.test/users" matches that name exactly. There is no wildcard for the subdomain part, so {tenant}.example.test/ is not a valid pattern, and with a table of customers that changes while the server runs, you would not want routes registered per tenant anyway. The resolver is data driven: a lookup from a normalized host to a tenant record, with the mux kept for paths.

Normalize, then look up

Hosts arrive in more shapes than people expect, and the Host header is client input like any other. Browsers send r.Host with the port when it is not the default (acme.example.test:8080 in development), DNS names are case insensitive but the header keeps whatever the client wrote, and a name can carry a trailing dot (acme.example.test.), which is legal and means the same thing. A lookup on the raw string misses all three. The function below handles them, and the table maps both kinds of address to a tenant.

main.goGo
package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
)

type Tenant struct {
	ID   string
	Name string
}

type ctxKey struct{}

// FromContext returns the tenant the resolver stored. Handlers never read the Host header themselves.
func FromContext(ctx context.Context) (Tenant, bool) {
	t, ok := ctx.Value(ctxKey{}).(Tenant)
	return t, ok
}

const baseDomain = "example.test"

// Resolver maps normalized hosts to tenants. In a real service this is a cached database lookup.
type Resolver struct {
	subdomains map[string]Tenant // "acme" -> tenant
	custom     map[string]Tenant // "shop.acme.com" -> tenant
}

func normalize(hostport string) string {
	host := hostport
	if h, _, err := net.SplitHostPort(hostport); err == nil {
		host = h
	}
	return strings.TrimSuffix(strings.ToLower(host), ".")
}

func (rs *Resolver) resolve(host string) (Tenant, bool) {
	if t, ok := rs.custom[host]; ok {
		return t, true
	}
	if sub, ok := strings.CutSuffix(host, "."+baseDomain); ok && !strings.Contains(sub, ".") {
		t, ok := rs.subdomains[sub]
		return t, ok
	}
	return Tenant{}, false
}

func (rs *Resolver) Middleware(marketing http.Handler, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		host := normalize(r.Host)
		if host == baseDomain || host == "www."+baseDomain {
			marketing.ServeHTTP(w, r) // the product's own site, no tenant
			return
		}
		t, ok := rs.resolve(host)
		if !ok {
			http.NotFound(w, r) // says nothing about which names exist
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, t)))
	})
}

func main() {
	rs := &Resolver{
		subdomains: map[string]Tenant{
			"acme":   {ID: "t_1", Name: "Acme Corp"},
			"globex": {ID: "t_2", Name: "Globex"},
		},
		custom: map[string]Tenant{
			"shop.acme.com": {ID: "t_1", Name: "Acme Corp"},
		},
	}
	marketing := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "marketing site") })
	app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t, _ := FromContext(r.Context())
		fmt.Fprintf(w, "dashboard for %s (%s)", t.Name, t.ID)
	})
	handler := rs.Middleware(marketing, app)

	for _, host := range []string{
		"acme.example.test",
		"ACME.Example.Test:8080",
		"acme.example.test.",
		"shop.acme.com",
		"globex.example.test",
		"example.test",
		"unknown.example.test",
		"evil.acme.example.test",
		"acme.example.test.evil.com",
		"",
	} {
		req := httptest.NewRequest("GET", "/", nil)
		req.Host = host
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		fmt.Printf("%-28q -> %d %s\n", host, rec.Code, strings.TrimSpace(rec.Body.String()))
	}
}
Text
"acme.example.test"          -> 200 dashboard for Acme Corp (t_1)
"ACME.Example.Test:8080"     -> 200 dashboard for Acme Corp (t_1)
"acme.example.test."         -> 200 dashboard for Acme Corp (t_1)
"shop.acme.com"              -> 200 dashboard for Acme Corp (t_1)
"globex.example.test"        -> 200 dashboard for Globex (t_2)
"example.test"               -> 200 marketing site
"unknown.example.test"       -> 404 404 page not found
"evil.acme.example.test"     -> 404 404 page not found
"acme.example.test.evil.com" -> 404 404 page not found
""                           -> 404 404 page not found

Read the list from the bottom up, because the interesting rows are the hostile ones. A host that merely ends in your name (acme.example.test.evil.com) is not a subdomain, and strings.CutSuffix with the leading dot is what prevents the classic strings.HasSuffix(host, "example.test") mistake, which also accepts evilexample.test. A name with extra labels (evil.acme.example.test) is rejected by the "no dots in the subdomain part" rule, so a tenant named acme cannot be confused with a sub-subdomain you never issued. And an empty host, which an HTTP/1.0 client can send, is a 404 like the others.

The first four rows resolve to the same tenant for three different spellings plus the custom domain, which is the reason there is one table and one code path. A custom domain is not special; it is another row, added when the customer proves they control the name.

The rules I would not bend

Never fall back to a default tenant. It is tempting during development: the host is not recognised, so use tenant 1. In production that sends a request for an unknown name straight into a real customer's data, and a mistyped DNS record turns into a data leak. Unknown means 404, as above. OWASP lists the host header injection pattern for a reason. Treat a missing tenant in a handler the same way: FromContext returning false is a bug in wiring and should produce a 500 you notice, not a guess.

The tenant comes from the resolver and nowhere else. Handlers should not read r.Host, a X-Tenant-ID header or a path segment to decide whose data to load. The unexported ctxKey type means no other package can put a tenant into the context, so the only way to have one is to have gone through the middleware. Every query then takes the tenant ID from that value, which is the real isolation boundary. If you use Postgres, row level security keyed on a session variable gives you a second layer, covered in the multi-tenant API article later in the series.

Be careful with forwarded hosts. Behind a load balancer, r.Host may be the internal name and the public one arrives in X-Forwarded-Host. Read that header only if you know the request came through your own proxy, for instance by checking the connection's source address against your proxy ranges, or by having the proxy strip any client-supplied value and set its own. Trusting it blindly lets any client choose their tenant by adding a header, which is the same bug as a default tenant with extra steps.

Cache the lookup, and invalidate it. A database query per request on the hot path is a cost you will notice, and a long-lived in-process cache means a deleted tenant keeps working until the entry expires. A short TTL (30 to 60 seconds) with explicit invalidation when a tenant changes is a reasonable middle. Use a plain map behind a sync.RWMutex or a small TTL cache, and do not cache negative results for long, or a newly added customer domain returns 404 for minutes after you create it.

Cookies, CORS and certificates

Three side effects of host based tenants catch teams late.

Cookies scope to a host. A session cookie set on acme.example.test is not sent to globex.example.test, which is exactly what you want, so do not set Domain=example.test on session cookies. With a Domain attribute the cookie is shared across every subdomain, and a tenant who can run scripts on their own subdomain can read or overwrite another tenant's cookie. Leave Domain unset.

Certificates have to cover the names. A wildcard certificate (*.example.test) handles every subdomain, and each custom domain needs its own, which is where autocert earns its place: its host policy can consult the same tenant table, so you only get certificates for domains a customer registered.

CORS allow-lists follow the same table. If tenants' front ends call each other's APIs, derive the allowed origin from the resolver rather than keeping a second list that drifts, and review the rules in the CORS article.

Testing it

The table-driven style used above is the right test: a slice of (host, expected status, expected body) rows run through the handler with httptest. Add one row every time you think of a hostile host, and include the trailing dot, the uppercase, the port and the lookalike suffix. A tenant leak is the most expensive bug a multi tenant service can have, and these rows cost a minute each.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.