DEV Community

Akash Devdhar
Akash Devdhar

Posted on Originally published at akashdevdhar.com

Designing Multi-Tenant Identity Systems

Most multi-tenant applications are one missing filter away from becoming somebody else's data breach. That sounds dramatic, but the architecture is often exactly this: accept a tenant_id from the request, add it to a SQL query, and call the system isolated. It is not isolated. It is trusting the caller to tell you which security boundary should apply, which is basically asking the request to authorize itself.

A tenant is not just a row partition. It is an identity and authorization boundary, full stop. If that boundary is not established from a verified identity and enforced at every layer that touches protected data, then the application is multi-tenant only in the product brochure.

The dangerous version looks reasonable

The most common design is attractive because it is simple. A frontend knows the current tenant, so it sends X-Tenant-ID. The API reads the header and uses it in every repository query. Engineers review the query and see WHERE tenant_id = ?, so everybody feels safe.

The issue is not the filter. The issue is who supplied its value.

app.get("/invoices", async (req, res) => {
  const tenantId = req.header("X-Tenant-ID");

  const invoices = await db.invoice.findMany({
    where: { tenantId },
  });

  res.json(invoices);
});
Enter fullscreen mode Exit fullscreen mode

An authenticated user can change a header only. If the API does not independently prove that the user belongs to that tenant, the filter is doing exactly what the attacker requested. Authentication succeeded, the database query is scoped, and the data is still wrong. This is why authentication and tenant isolation cannot be separate conversations.

Caller-controlled tenant context creates a false security boundary

What I am noticing is that teams are putting tenant selection inside application state, when it belongs inside verified security context. The browser can request a tenant switch, of course, but the identity layer must decide whether that switch is permitted and mint or establish the resulting context. The browser does not get to declare it as fact.

Derive tenant context from identity

The safer model begins with a verified session or access token. That identity carries a subject, an audience, and either a tenant claim or enough membership information for the authorization service to resolve one. The API validates the token, confirms membership, then creates an internal request context that downstream code cannot replace with a header or request-body field.

type TenantContext = {
  subjectId: string;
  tenantId: string;
  scopes: Set<string>;
};

async function buildTenantContext(req: Request): Promise<TenantContext> {
  const token = await verifyAccessToken(req.headers.authorization);
  const requestedTenant = req.headers["x-tenant-id"];

  if (typeof requestedTenant !== "string") {
    throw new Error("tenant context is required");
  }

  const membership = await memberships.findActive(
    token.subject,
    requestedTenant,
  );

  if (!membership) {
    throw new Error("subject is not a member of this tenant");
  }

  return {
    subjectId: token.subject,
    tenantId: membership.tenantId,
    scopes: new Set(token.scopes),
  };
}
Enter fullscreen mode Exit fullscreen mode

The header still exists here, but it is a request to select from the user's verified memberships, not proof of membership by itself. That difference is the whole model actually.

For an AI agent, the context needs one more piece: delegation. The agent's own identity tells you which workload is calling. It does not automatically prove which customer or human authorized this task. A useful agent token or token-exchange result should preserve both the agent identity and the delegated subject, with a narrow tenant and audience. Otherwise one agent credential can quietly become a bridge between every customer the service supports.

OAuth 2.0 Token Exchange, defined in RFC 8693, is useful here because a service can exchange an upstream token for a narrower downstream token. The new token can target one resource server and one tenant context rather than forwarding a broad credential through the full service chain. OpenID Connect claims establish identity, but your authorization policy still has to decide how those claims map to tenant membership.

Carry the boundary through every hop

Verifying tenant membership at the first API is necessary, but it is not enough only. Multi-tenant systems usually have queues, background jobs, internal APIs, caches, and agents calling tools. Tenant context gets lost at these boundaries because developers serialize the business payload and forget the security context that made the action valid.

A background job should never wake up with only an invoiceId and then search globally. It should carry the tenant identifier, initiating subject, and authorization purpose, and the worker should verify those values before acting. Internal services should receive a signed, audience-bound token rather than a forwarded browser header. Cache keys must include the tenant boundary also, otherwise perfectly authorized reads can leak through a shared cache.

Verified tenant context is carried and enforced through every service hop

The important part in this design is that each service treats tenant context as security input. It is not ordinary metadata that can be dropped, overwritten, or filled with a default when missing. Missing tenant context should fail closed.

Enforce isolation again at the data layer

Application checks are valuable, but relying on every engineer to remember every filter forever is a weak last line of defense. The repository or database layer should make cross-tenant access difficult by construction.

PostgreSQL Row-Level Security is one option. The application sets verified tenant context on the database session, and a policy restricts rows regardless of which query the application writes.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
Enter fullscreen mode Exit fullscreen mode

This is not magic. Connection pooling has to reset session state correctly, privileged database roles must not bypass the policy casually, and migrations need testing. But it changes the failure mode. A forgotten application filter becomes a denied query instead of a customer-data incident.

Some systems choose separate schemas or separate databases for stronger isolation. That can be the correct decision for regulatory or high-value tenants, but it adds operational cost. The point is not that one storage model wins everywhere. The point is that the isolation strength should match the consequence of crossing the boundary, and it should not depend on a single controller remembering a WHERE clause.

Tenant membership is lifecycle data

Identity architecture tends to focus on login, while tenant membership keeps changing after login. Users join organizations, leave them, switch roles, and sometimes belong to several tenants at once. Agents get reassigned or disabled. A token issued yesterday can carry authorization that is no longer true today.

Keep access tokens short-lived. Re-evaluate membership when selecting a tenant or performing sensitive work. Use SCIM where it fits for enterprise provisioning and deprovisioning, but do not assume that provisioning alone gives you runtime authorization. SCIM can tell your system that a user was removed. Your session and token design determines how quickly that removal actually stops access.

Audit records should capture at least the effective tenant, the human or workload subject, delegated identity when present, the action, and the authorization decision. Logging only user_id is not enough in a system where the same subject can act inside multiple customer boundaries.

The takeaway

The tenant boundary must come from identity, travel with the request, and be enforced where the data lives. If any layer can guess it, default it, or accept it without verification, then that layer can cross it also.

A tenant_id column is useful data modeling. A verified tenant context is security architecture. Multi-tenant systems need both, and confusing one for the other is how an application ends up showing the right query in code review and the wrong customer's data in production.

Akash Devdhar is a Senior Software Engineer specializing in enterprise identity, authentication, authorization, and AI infrastructure. He writes about building secure AI systems using OAuth, OIDC, RBAC, and modern identity architectures.

Top comments (0)