How to Build Tenant Routing and Subdomain Resolution in Cloud Architecture
A technical guide to implementing wildcard subdomains, custom vanity domains, reverse-proxy SSL termination, and tenant context resolution.
When building a modern cloud software application, one of your earliest architectural milestones is giving each customer an isolated workspace experience. Whether an enterprise accesses your platform via a branded subdomain like acme.yourproduct.com or maps their own corporate vanity domain like portal.acme.com, your infrastructure must parse the incoming HTTP request, identify the associated customer tenant, and securely propagate that tenant context to backend handlers and database transactions.
If you handle tenant routing incorrectly, you risk severe vulnerabilities: HTTP Host Header Injection, cache poisoning, subdomain takeover, or routing cross-tenant traffic to the wrong database schema. This comprehensive guide walks you through building an industrial-grade tenant routing and subdomain resolution pipeline, from reverse-proxy TLS termination down to context-bound database queries.
Understanding the Three Tenant Routing Models
cloud software architectures typically support three different URL routing schemes:
- Subdomain-per-Tenant (
acme.app.io): Each registered organization receives a unique subdomain under your primary domain. This is the gold standard for usability, session separation, and cookie security. - Custom Vanity Domains (
portal.customer.com): High-paying enterprise customers map their corporate DNS to your application using a CNAME record, providing a white-labeled experience. - Path-Based Multi-Tenancy (
app.io/org/acme/): All tenants share the root domain, with tenant slugs placed in the URL path. While easier to configure in DNS, path-based routing shares browser cookies across all organizations, complicating SSO and increasing cross-site scripting (XSS) blast radius.
The recommended architectural approach is supporting subdomains as the default, while allowing an opt-in upgrade to custom vanity domains for enterprise contracts.
| Routing Model | Example URL | TLS Management | Cookie Isolation | Suitable Customer |
|---|---|---|---|---|
| Wildcard Subdomain | acme.app.io |
Single wildcard cert (*.app.io) |
Subdomain-scoped cookies | Self-Serve / Standard Tiers |
| Custom Domain | portal.acme.com |
Dynamic per-domain ACME or SNI proxy | Completely isolated origin | Enterprise White-Label |
| Path-Based | app.io/org/acme/ |
Single primary cert (app.io) |
Shared cookie across all tenants | Internal tools / B2C |
TLS Termination and Certificate Provisioning
Routing subdomains begins at the network perimeter. Your edge proxy must terminate TLS connections securely before forwarding traffic to your backend application cluster.
1. Wildcard Certificates for Subdomains
For your primary platform subdomains (*.yourproduct.com), you should provision a wildcard TLS certificate using the ACME protocol via Let's Encrypt or your cloud provider. Wildcard certificates require a DNS-01 challenge, where your automation system creates a temporary _acme-challenge.yourproduct.com TXT record in your DNS provider.
Once issued, the single wildcard certificate covers every current and future customer subdomain without runtime latency or certificate issuance delays.
2. Dynamic SNI for Custom Customer Domains
Custom vanity domains (app.customer.com) cannot be included in your wildcard certificate because you do not own the customer's root domain. When a customer points a CNAME record to your ingress router (custom.yourproduct.io), your edge proxy (such as Envoy, Caddy, or edge ingress controllers) must:
- Intercept the incoming TLS Server Name Indication (SNI) handshake.
- Query your routing database to verify that
app.customer.comis an authorized, active tenant domain. - Dynamically obtain and cache a single-domain certificate via an ACME HTTP-01 challenge.
[ Browser Client ]
|
(TLS SNI: portal.customer.com)
v
[ Edge Proxy (Envoy / Caddy) ]
|
+---> [ Verify Domain in Tenant DB ]
|
+---> [ Serve Cached Dynamic TLS Cert ]
|
(X-Tenant-ID: tnt_8819 + X-Forwarded-Host)
v
[ Application Core ]
Designing the Tenant Resolution Middleware
Once TLS is terminated, the request hits your origin web server. Your application must extract the host, sanitize the input, look up the tenant in a fast cache, and store the validated tenant entity inside the request context.
Here is a battle-tested middleware implementation:
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"time"
)
type contextKey string
const TenantContextKey contextKey = "current_tenant"
type Tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Tier string `json:"tier"`
Status string `json:"status"` // "active", "suspended"
CreatedAt time.Time `json:"createdAt"`
}
type TenantStore interface {
FindByHostname(ctx context.Context, host string) (*Tenant, error)
}
// TenantResolver handles domain normalization and caching
type TenantResolver struct {
store TenantStore
baseDomain string
cache sync.Map // In-memory fast cache (hostname -> *cachedTenant)
cacheTTL time.Duration
}
type cachedTenant struct {
tenant *Tenant
expiresAt time.Time
}
func NewTenantResolver(store TenantStore, baseDomain string, ttl time.Duration) *TenantResolver {
return &TenantResolver{
store: store,
baseDomain: strings.ToLower(baseDomain),
cacheTTL: ttl,
}
}
func (tr *TenantResolver) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract and sanitize Host header
host := strings.ToLower(r.Host)
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx] // Strip port number if present
}
// 2. Bypass public marketing endpoints
if host == tr.baseDomain || host == "www."+tr.baseDomain {
next.ServeHTTP(w, r)
return
}
// 3. Resolve tenant entity with caching
tenant, err := tr.resolve(r.Context(), host)
if err != nil {
http.Error(w, "Workspace not found or domain unmapped", http.StatusNotFound)
return
}
if tenant.Status != "active" {
http.Error(w, "Workspace is suspended or inactive", http.StatusForbidden)
return
}
// 4. Inject tenant into Request Context
ctx := context.WithValue(r.Context(), TenantContextKey, tenant)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (tr *TenantResolver) resolve(ctx context.Context, host string) (*Tenant, error) {
now := time.Now()
// Check L1 sync.Map cache
if val, ok := tr.cache.Load(host); ok {
cached := val.(*cachedTenant)
if now.Before(cached.expiresAt) {
return cached.tenant, nil
}
tr.cache.Delete(host)
}
// Query authoritative store (DB / Redis)
tenant, err := tr.store.FindByHostname(ctx, host)
if err != nil {
return nil, err
}
tr.cache.Store(host, &cachedTenant{
tenant: tenant,
expiresAt: now.Add(tr.cacheTTL),
})
return tenant, nil
}
Preventing Cache Stampedes with Singleflight
During peak morning hours or deployment restarts, hundreds of concurrent requests for the same tenant domain might hit your web servers simultaneously when your local cache is cold. If you query your relational database for every concurrent cache miss, your connection pool will spike, leading to database saturation.
To solve this, wrap your tenant database queries with the standard singleflight deduplication pattern:
import "golang.org/x/sync/singleflight"
type SafeTenantStore struct {
db Database
group singleflight.Group
}
func (s *SafeTenantStore) FindByHostname(ctx context.Context, host string) (*Tenant, error) {
// Deduplicate concurrent requests for the identical host
v, err, _ := s.group.Do(host, func() (interface{}, error) {
return s.db.LookupTenantByDomain(ctx, host)
})
if err != nil {
return nil, err
}
return v.(*Tenant), nil
}
singleflight ensures that if 500 requests arrive for acme.app.io at the same microsecond, only one SQL query executes against your database. The other 499 requests block and share the single result, protecting your database from sudden traffic spikes.
Security Considerations: Host Header Poisoning and Hijacking
Tenant routing introduces distinct security risks that must be defended against in code:
| Threat Vector | Mechanism | Architectural Defense |
|---|---|---|
| Host Header Injection | Attacker sends malicious Host: attacker.com header to trigger password reset links pointing to attacker's server |
Strict validation of incoming hostname against known tenant domain database; reject unmatched hosts |
| Subdomain Takeover | Tenant deletes their account, leaving DNS pointing to your cluster; attacker registers deleted slug | Implement a 90-day cooldown reservation on deleted subdomains before reuse |
| Wildcard Cookie Bleed | Cookie scoped to .app.io readable by any compromised tenant subdomain |
Scope session cookies to the specific tenant subdomain (acme.app.io), not the parent root |
| Cross-Tenant Impersonation | Attacker sends spoofed X-Tenant-ID header directly to API |
Strip all client-supplied X-Tenant-* headers at the reverse proxy ingress |
Practical Next Steps
- Configure Wildcard DNS: Set an
AorCNAMErecord for*.yourdomain.iopointing to your ingress load balancer. - Deploy Tenant Resolution Middleware: Integrate the middleware above into your HTTP router pipeline, ensuring it executes before session authentication and database transactions.
- Establish Subdomain Blacklists: Prevent users from registering administrative or system subdomains (
api,admin,billing,status,mail,auth,support). - Enforce Context Type Safety: Use an internal helper function (e.g.
tenant.FromContext(ctx)) rather than raw string keys to retrieve the tenant model in downstream handlers.
Frequently Asked Questions
How should custom customer domains (e.g., portal.acme.com) point to our cloud application?
Instruct the customer to configure a DNS CNAME record pointing their chosen subdomain to your proxy entry point (such as custom.yourdomain.io). Your edge proxy handles dynamic SSL issuance via automated ACME HTTP-01 challenges or edge ingress controllers.
Can an attacker spoof the tenant ID by injecting an HTTP header?
Not if your edge proxy strips all incoming X-Tenant-ID headers from external clients before setting its own verified value based on authenticated sessions or validated hostnames.
How long should tenant routing metadata be cached in memory?
A local in-memory TTL of 60 to 300 seconds with randomized jitter strikes an optimal balance between fast domain renames and reducing Redis lookup latency to under 0.5 milliseconds.
What is the recommended fallback behavior when an unknown subdomain is requested?
Return a clean HTTP 404 Not Found page explaining that the workspace does not exist or has been suspended, with a link to account registration, avoiding redirect loops.
How do you handle routing for multi-tenant mobile applications where Host headers cannot vary by tenant?
Mobile clients authenticate against a centralized auth service that returns an organization-scoped JWT. The API gateway extracts the tenant_id claim directly from the cryptographically verified JWT rather than relying on subdomains.
Does Let's Encrypt support wildcard SSL for custom enterprise vanity domains?
No. Let's Encrypt issues wildcard certificates (*.customer.com) only via DNS-01 challenges, which requires control over customer DNS servers. For custom vanity domains, you must issue dedicated single-domain certificates via HTTP-01 challenges.