net/go.book
All Parts Marketing

APIs for Multi-Tenancy and SaaS

A multi-tenant API serves many independent customers ("tenants") from the same running application and, usually, the same database — a project management tool where Acme Corp and Globex Inc both use the product without ever seeing each other's data, backed by the same Go binary and the same Postgres instance. The entire discipline of multi-tenancy comes down to one question asked at every layer of the stack: how do you guarantee that a request scoped to one tenant can never read or write another tenant's data, even when a developer makes a mistake three years from now.

Identifying the tenant

Before anything else, every request needs to be unambiguously associated with exactly one tenant. The three common mechanisms are a subdomain (acme.myapp.com), a header (X-Tenant-ID), or a claim embedded in the auth token itself. The token-based approach is generally the most robust, because it ties tenant identity to something already cryptographically verified rather than to a client-supplied value that could be spoofed:

type contextKey string

const tenantContextKey contextKey = "tenant"

func tenantMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, ok := r.Context().Value(claimsContextKey).(*JWTClaims)
		if !ok {
			http.Error(w, "unauthenticated", http.StatusUnauthorized)
			return
		}
		ctx := context.WithValue(
			r.Context(), tenantContextKey, claims.TenantID,
		)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func tenantFromContext(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(tenantContextKey).(string)
	return id, ok
}

This middleware runs after authentication and before every handler that touches tenant data, so tenantFromContext is the single source of truth downstream code relies on — no handler should ever read a tenant ID from a query parameter or request body, because that would let one tenant simply ask for another tenant's ID.

Never trust a client-supplied tenant ID
If a tenant identifier can be set via a header or query string the client controls, it can be forged. Derive it from something the server verified — a signed token claim, or a session looked up server-side — never from raw request input.

Isolating data: three levels of strictness

Separate databases per tenant gives the strongest isolation — a bug in one tenant's query can't possibly touch another tenant's database, because there's no shared connection at all. It also means running migrations and backups per tenant, which becomes operationally expensive past a few dozen tenants.

Separate schemas, shared database is a middle ground common in Postgres-based SaaS: each tenant gets its own schema (acme.orders, globex.orders) inside one database, and the API selects the schema per request via the connection's search path. It keeps operational overhead lower than fully separate databases while still preventing an unqualified query from accidentally touching another tenant's rows.

Shared tables with a tenant_id column is the most common approach for products with many tenants, because it scales operationally the best — one schema, one migration path, one set of indexes. The entire safety of this model rests on every single query filtering by tenant_id, with no exceptions:

func listOrders(ctx context.Context, db *sql.DB) ([]Order, error) {
	tenantID, ok := tenantFromContext(ctx)
	if !ok {
		return nil, errors.New("no tenant in context")
	}
	rows, err := db.QueryContext(ctx,
		`SELECT id, total FROM orders WHERE tenant_id = $1`, tenantID)
	// ...
}

Per-tenant configuration and limits

SaaS products commonly need per-tenant behavior beyond data isolation: different rate limits for a free tier versus an enterprise plan, different feature availability, different webhook endpoints. Rather than sprinkling if tenant.Plan == "enterprise" checks through handlers, centralize tenant configuration behind a lookup keyed by tenant ID, loaded once per request (and cached, since it rarely changes):

type TenantConfig struct {
	Plan            string
	RateLimitPerMin int
	FeatureFlags    map[string]bool
}

func loadTenantConfig(ctx context.Context, tenantID string) (TenantConfig, error) {
	if cfg, ok := tenantConfigCache.Get(tenantID); ok {
		return cfg.(TenantConfig), nil
	}
	cfg, err := fetchTenantConfigFromDB(ctx, tenantID)
	if err == nil {
		tenantConfigCache.Set(tenantID, cfg, 5*time.Minute)
	}
	return cfg, err
}

This keeps plan-based logic in one place, and it means a customer upgrading their plan takes effect within the cache TTL rather than requiring a deploy.

Noisy neighbors

Shared infrastructure means one tenant's traffic spike can degrade service for everyone else — the "noisy neighbor" problem. Per-tenant rate limiting (using the tenant ID as the key, instead of a global limit) contains this at the API layer:

func perTenantRateLimit(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tenantID, _ := tenantFromContext(r.Context())
		// One token bucket per tenant, cached across requests.
		limiter := limiterFor(tenantID)
		if !limiter.Allow() {
			http.Error(w, "rate limit exceeded",
				http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Testing tenant isolation as a first-class concern

Isolation bugs are among the most expensive to discover in production, because discovering one means a customer already saw another customer's data. Write tests that specifically try to break isolation, not just tests that verify the happy path works for one tenant at a time:

func TestTenantIsolation(t *testing.T) {
	acmeCtx := contextForTenant("acme")
	globexCtx := contextForTenant("globex")

	order := createOrder(acmeCtx, db, Order{Total: 100})

	_, err := getOrder(globexCtx, db, order.ID)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected globex to not see acme's order, got: %v", err)
	}
}

This test asserts something more specific than "the query works" — it asserts that a tenant explicitly attempting to read another tenant's resource by ID gets a not-found result, not a permission error that would reveal the resource exists at all, and not the resource itself. Running this pattern against every resource type in the system, as part of the normal test suite rather than a one-off manual check, is what actually catches a missing tenant_id filter before a customer does.

Frequently Asked Questions

Why derive the tenant ID from a JWT claim instead of just trusting an X-Tenant-ID header? Because anything the client sets on a request can be forged by that same client — a header is just a string a curious or malicious user can edit before sending the request. A claim inside a token your server already verified the signature of is tied to something cryptographically checked, which is why tenantMiddleware reads it from claims.TenantID in the request context rather than from a header.

With shared tables and a tenant_id column, isn't it just a matter of time before someone forgets the WHERE clause? Yes, and that's exactly the gap Postgres row-level security is there to close. Application-level filtering is only as strong as every developer's discipline forever, so pairing it with an RLS policy that enforces tenant_id = current_setting('app.tenant_id') at the database level means a forgotten filter fails safely instead of leaking a row.

Why does TestTenantIsolation check for ErrNotFound instead of a permission-denied error? Because a permission error would confirm to Globex that an order with that ID exists at all, which is itself information leakage about Acme's account — order volume, ID sequencing, or just the bare fact of the resource's existence. Returning not-found for a resource that exists but belongs to someone else is the same response shape as a resource that never existed, so no side channel is available.

Should every tenant get their own database, or is the shared-table approach with tenant_id always fine? It depends on how many tenants you expect and how strict the isolation guarantee needs to be — separate databases per tenant give the strongest guarantee but become expensive to migrate and back up past a few dozen tenants, while shared tables scale operationally the best but put all the isolation weight on consistent query filtering (ideally backed by RLS). Separate schemas in one database is the middle ground worth reaching for if you want stronger isolation than a shared table without the operational cost of fully separate databases.

How is per-tenant rate limiting different from the general rate limiting you'd add to any API? A global rate limit protects the service as a whole but does nothing to stop one tenant's traffic spike from starving every other tenant sharing the same infrastructure — the "noisy neighbor" problem. Keying the limiter by tenant ID, as perTenantRateLimit does, means one tenant hitting their ceiling only affects that tenant's own token bucket, leaving everyone else's quota untouched.

In a multi-tenant system, every shared resource is a potential leak or a potential bottleneck — isolation and fairness are the same design problem viewed from two angles.

Getting multi-tenancy right is less about clever code and more about consistency: pick one place tenant identity is established, one place data is scoped, and make deviating from that path harder than following it.