Get a quote

Propagating Tenant Context Through a Go SaaS Backend: Middleware, Repositories, and Safe Isolation

In a multi-tenant SaaS backend, the tenant identity must travel with every request from the HTTP middleware layer through service logic to the database query. Missing a single boundary lets one tenant read another tenant's data. This is the architecture we use for safe tenant context propagation in production Go SaaS backends.

In a multi-tenant SaaS backend, the tenant identity must travel with every request from the HTTP middleware layer through service logic to the database query. Missing a single boundary lets one tenant read another tenant's data. This is the architecture we use for safe tenant context propagation in production Go SaaS backends serving clients across Lebanon and the MENA region.

The problem with global or implicit tenant resolution

A common mistake in early-stage multi-tenant Go backends is resolving the tenant at the database layer based on a header or JWT claim that is passed as a function parameter. The function signature looks like:

func (r *OrderRepository) GetOrders(tenantID uuid.UUID) ([]Order, error)

This appears safe but creates two practical problems. First, it is easy for a developer to pass the wrong tenant ID. When tenantID comes from a variable that might have been set earlier in the request handler, there is no compiler enforcement that the value is actually the authenticated tenant's ID. Second, as services call other services and repositories call other repositories, the tenantID parameter must be threaded through every call in the chain explicitly. Developers skip this in deep call stacks, and the result is a query that either fetches all tenants' data or errors with a missing parameter.

Using Go context to carry tenant identity

The correct approach is to store the authenticated tenant ID in context.Context at the HTTP middleware layer and retrieve it at the repository layer. This makes the tenant identity invisible to intermediate layers while guaranteeing it travels with every operation.

The middleware extracts the tenant identity from the JWT claims after token validation and stores it in the context:

type contextKey string
const tenantContextKey contextKey = "tenant"

type TenantContext struct {
    ID     uuid.UUID
    Slug   string
    Plan   string
}

func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        claims, ok := jwtClaimsFromContext(r.Context())
        if !ok {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        tenant := TenantContext{
            ID:   claims.TenantID,
            Slug: claims.TenantSlug,
            Plan: claims.Plan,
        }
        ctx := context.WithValue(r.Context(), tenantContextKey, tenant)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func TenantFromContext(ctx context.Context) (TenantContext, bool) {
    t, ok := ctx.Value(tenantContextKey).(TenantContext)
    return t, ok
}

The repository layer calls TenantFromContext to get the tenant identity before any database query:

func (r *OrderRepository) GetOrders(ctx context.Context) ([]Order, error) {
    tenant, ok := TenantFromContext(ctx)
    if !ok {
        return nil, errors.New("no tenant in context: unauthorized")
    }
    rows, err := r.db.Query(ctx,
        "SELECT * FROM orders WHERE tenant_id = $1",
        tenant.ID)
    // ...
}

If the tenant context is missing (because the middleware was accidentally skipped, or because a background goroutine lost the original context), the repository returns an error rather than fetching all records. This is the critical safety property: the repository refuses to execute without a verified tenant identity.

Handling background goroutines

A common place where tenant context gets lost is when a handler spawns a background goroutine:

// WRONG: the goroutine uses a context that may be cancelled
go func() {
    processOrderAsync(r.Context(), orderID)
}()

When the HTTP handler returns, the request context is cancelled. A goroutine still running with the cancelled context will see ctx.Err() != nil immediately.

The correct pattern for spawning background work that needs tenant identity is to detach the work from the request lifecycle:

// Copy the tenant identity out of the request context
tenant, _ := TenantFromContext(r.Context())

go func() {
    // Create a fresh context (not cancelled by the request lifecycle)
    bgCtx := context.Background()
    // Re-inject the tenant identity into the fresh context
    bgCtx = context.WithValue(bgCtx, tenantContextKey, tenant)
    processOrderAsync(bgCtx, orderID)
}()

For background jobs started by ECS Scheduled Tasks or worker processes (not HTTP requests), inject the tenant context at the start of the job using the same context key. A job that processes a tenant's monthly invoice starts by setting the tenant context on its working context, then passes that context to all downstream calls.

Row-level security as a defense-in-depth layer

Context propagation handles isolation at the application layer. PostgreSQL Row-Level Security (RLS) provides a second isolation layer at the database layer.

With RLS configured for the orders table, even if a bug in the application layer passes the wrong tenant_id to a query, PostgreSQL will filter the result to only the rows matching the session's configured tenant. The application sets the tenant for the session at connection time:

SET app.current_tenant_id = '...';

In Go, this is handled in the connection pool's BeforeAcquire hook: before a connection is acquired from the pool, set the tenant parameter on the connection from the current context.

RLS adds about 5 to 15 microseconds of overhead per query on a warmed PostgreSQL instance. For most SaaS workloads this overhead is acceptable given the security guarantee. For extremely high-throughput workloads where every microsecond matters, use context-based isolation only and invest heavily in integration tests that verify cross-tenant isolation.

Testing cross-tenant isolation

Cross-tenant isolation bugs are among the most serious bugs in a SaaS system. Testing them requires specific test patterns.

For every repository method, write a test that:

  1. Creates records for tenant A and tenant B
  2. Queries with tenant A's context
  3. Asserts that no tenant B records appear in the result

This test class catches the most common isolation bug: a missing WHERE tenant_id = $1 clause in a query.

For end-to-end testing, create two test accounts in different tenants and run requests from each account against the same endpoints. Assert that each account sees only its own data. This catches bugs in the middleware layer where the wrong tenant identity might be set in the context.

Logging and tracing with tenant context

Structured logging should include the tenant ID on every log line emitted during a request. Use a logger middleware that enriches the logger with the tenant context after the tenant middleware runs:

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenant, ok := TenantFromContext(r.Context())
        if ok {
            logger := slog.With("tenant_id", tenant.ID, "tenant_slug", tenant.Slug)
            ctx := context.WithValue(r.Context(), loggerContextKey, logger)
            r = r.WithContext(ctx)
        }
        next.ServeHTTP(w, r)
    })
}

When a bug report comes in from a specific client (common in MENA SaaS where support is often handled directly by the founding team), you can filter logs by tenant_slug to see every operation that client performed in the relevant time window.

Key lessons from production

Make missing tenant context a hard error, not a silent behavior. A repository method that silently queries all tenants when the context is missing is a data breach waiting to happen.

Use context for propagation, not function parameters. Function parameters are dropped as call stacks grow. Context travels with the goroutine.

RLS is a defense-in-depth layer, not a substitute for application-layer isolation. Both layers should be present in a production multi-tenant system.

Test cross-tenant isolation explicitly on every repository method. These tests catch bugs that code review misses because the missing WHERE clause is invisible until you look for it in the query.

Include the tenant identifier in every structured log line. Debugging client-specific issues without per-tenant log filtering is extremely time-consuming in production.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

Voxire builds multi-tenant SaaS backends in Go for companies across Lebanon and the MENA region. If you are designing a multi-tenant system or troubleshooting isolation issues in an existing backend, we can help with the architecture.

https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp