Skip to main content
Technology & EngineeringAuth Patterns199 lines

Multi Tenancy

Multi-tenant authentication isolation, tenant-scoped tokens, and data boundary enforcement

Quick Summary18 lines
You are an expert in multi-tenant authentication and authorization patterns for SaaS applications. You understand tenant isolation strategies, tenant-aware token design, cross-tenant access prevention, and the trade-offs between shared and siloed architectures.

## Key Points

- **Tenant**: An isolated organizational unit within a shared application (e.g., a company account).
- **Tenant Context**: The currently active tenant for a request, established during authentication or via a header/subdomain.
- **Tenant Isolation**: Ensuring that queries, API calls, and background jobs never cross tenant boundaries.
- **Shared Infrastructure**: All tenants share the same application instances and (often) the same database, with logical isolation.
- **Siloed Infrastructure**: Each tenant gets dedicated database instances, compute, or even clusters.
- **Tenant-Scoped Tokens**: JWTs or sessions that embed the tenant ID as a claim, binding the token to a specific tenant.
- **Cross-Tenant Access**: Controlled patterns where a user belongs to multiple tenants and can switch between them.
1. **Embed tenant_id in the JWT** so that tenant context is cryptographically bound to the authenticated session — never rely solely on client-supplied headers.
2. **Use PostgreSQL Row-Level Security (RLS)** as a defense-in-depth layer. Even if application code has a bug, RLS prevents cross-tenant data access at the database level.
3. **Always reset tenant context** on database connections before returning them to the pool.
4. **Validate tenant membership on every token issuance and refresh**, not just at initial login.
5. **Add tenant_id to every table** and include it in all indexes and unique constraints. A unique email per tenant requires `UNIQUE(tenant_id, email)`, not `UNIQUE(email)`.
skilldb get auth-patterns-skills/Multi TenancyFull skill: 199 lines
Paste into your CLAUDE.md or agent config

Multi-Tenant Authentication — Authentication & Authorization

You are an expert in multi-tenant authentication and authorization patterns for SaaS applications. You understand tenant isolation strategies, tenant-aware token design, cross-tenant access prevention, and the trade-offs between shared and siloed architectures.

Core Philosophy

Overview

Multi-tenant authentication ensures that users are authenticated and authorized within the correct tenant context, and that data and operations from one tenant can never leak to another. A tenant is typically an organization, company, or account that shares the same application infrastructure but must be completely isolated in terms of data access and permissions. Getting tenant isolation wrong is a critical security vulnerability.

Core Concepts

  • Tenant: An isolated organizational unit within a shared application (e.g., a company account).
  • Tenant Context: The currently active tenant for a request, established during authentication or via a header/subdomain.
  • Tenant Isolation: Ensuring that queries, API calls, and background jobs never cross tenant boundaries.
  • Shared Infrastructure: All tenants share the same application instances and (often) the same database, with logical isolation.
  • Siloed Infrastructure: Each tenant gets dedicated database instances, compute, or even clusters.
  • Tenant-Scoped Tokens: JWTs or sessions that embed the tenant ID as a claim, binding the token to a specific tenant.
  • Cross-Tenant Access: Controlled patterns where a user belongs to multiple tenants and can switch between them.

Implementation Patterns

Tenant Resolution Middleware

// Resolve tenant from subdomain, header, or path
function resolveTenant(req, res, next) {
  let tenantId = null;

  // Strategy 1: Subdomain (acme.app.example.com)
  const host = req.hostname;
  const subdomain = host.split('.')[0];
  if (subdomain && subdomain !== 'app' && subdomain !== 'www') {
    tenantId = subdomain;
  }

  // Strategy 2: Custom header (for APIs)
  if (!tenantId && req.headers['x-tenant-id']) {
    tenantId = req.headers['x-tenant-id'];
  }

  // Strategy 3: JWT claim (most secure — server-issued, tamper-proof)
  if (!tenantId && req.user?.tenantId) {
    tenantId = req.user.tenantId;
  }

  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant context required' });
  }

  req.tenantId = tenantId;
  next();
}

Tenant-Scoped JWT Issuance

function issueTokenForTenant(user, tenantId) {
  // Verify user actually belongs to this tenant
  const membership = user.memberships.find(m => m.tenantId === tenantId);
  if (!membership) {
    throw new Error('User is not a member of this tenant');
  }

  return jwt.sign(
    {
      sub: user.id,
      tenant_id: tenantId,
      roles: membership.roles,
      permissions: membership.permissions,
    },
    process.env.JWT_PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '15m' }
  );
}

// Tenant switch — issue new token for a different tenant
app.post('/auth/switch-tenant', authenticate, async (req, res) => {
  const { tenantId } = req.body;
  const user = await getUser(req.user.sub);

  const membership = user.memberships.find(m => m.tenantId === tenantId);
  if (!membership) {
    return res.status(403).json({ error: 'Not a member of this tenant' });
  }

  const accessToken = issueTokenForTenant(user, tenantId);
  res.json({ accessToken });
});

Row-Level Tenant Isolation (PostgreSQL + RLS)

-- Add tenant_id to every table
ALTER TABLE orders ADD COLUMN tenant_id UUID NOT NULL REFERENCES tenants(id);
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

-- Enable Row-Level Security
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Force RLS even for table owners
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
// Set tenant context on every database connection
async function withTenantContext(tenantId, callback) {
  const client = await pool.connect();
  try {
    await client.query("SET app.current_tenant_id = $1", [tenantId]);
    return await callback(client);
  } finally {
    // Reset to prevent leaking tenant context to the next user of this connection
    await client.query("RESET app.current_tenant_id");
    client.release();
  }
}

// Usage in a route handler
app.get('/api/orders', authenticate, resolveTenant, async (req, res) => {
  const orders = await withTenantContext(req.tenantId, async (db) => {
    // RLS automatically filters to current tenant — no WHERE clause needed
    return db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 50');
  });
  res.json(orders.rows);
});

Schema-Per-Tenant Isolation

from sqlalchemy import event
from sqlalchemy.orm import Session

def set_tenant_schema(session: Session, tenant_id: str):
    """Switch the search_path to the tenant's schema."""
    schema = f"tenant_{tenant_id}"
    session.execute(text(f"SET search_path TO {schema}, public"))

class TenantMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        tenant_id = extract_tenant_id(scope)
        scope["state"]["tenant_id"] = tenant_id
        await self.app(scope, receive, send)

# Provision a new tenant
async def provision_tenant(tenant_id: str):
    schema = f"tenant_{tenant_id}"
    async with engine.begin() as conn:
        await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema}"))
        # Run migrations against the new schema
        await run_migrations(conn, schema)

Best Practices

  1. Embed tenant_id in the JWT so that tenant context is cryptographically bound to the authenticated session — never rely solely on client-supplied headers.
  2. Use PostgreSQL Row-Level Security (RLS) as a defense-in-depth layer. Even if application code has a bug, RLS prevents cross-tenant data access at the database level.
  3. Always reset tenant context on database connections before returning them to the pool.
  4. Validate tenant membership on every token issuance and refresh, not just at initial login.
  5. Add tenant_id to every table and include it in all indexes and unique constraints. A unique email per tenant requires UNIQUE(tenant_id, email), not UNIQUE(email).
  6. Scope background jobs to a tenant. Pass tenant_id as a job parameter and set the database context before processing.
  7. Test cross-tenant isolation explicitly — write integration tests that attempt to access Tenant B's data with Tenant A's credentials.
  8. Log tenant_id in every request for auditing and debugging.

Common Pitfalls

  • Missing tenant filter in queries: A single SELECT * FROM orders without tenant context returns data from all tenants. RLS mitigates this, but application-level filtering is still the primary defense.
  • Connection pool tenant leakage: If a connection's tenant context is not reset before being returned to the pool, the next request on that connection may execute with the wrong tenant.
  • Trusting client-supplied tenant headers: An attacker can set X-Tenant-Id to any value. Always validate against the authenticated user's memberships.
  • Global unique constraints: UNIQUE(email) prevents the same email from existing in different tenants. Include tenant_id in uniqueness constraints.
  • Shared caches without tenant namespacing: Cache keys like orders:list will serve cached data from one tenant to another. Always prefix with tenant:{id}:.
  • Ignoring tenant context in webhooks and async workers: Background processes often lack HTTP context, so tenant_id must be explicitly passed and set.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add auth-patterns-skills

Get CLI access →