multi-tenancy

Multi-tenancy patterns for SaaS: row-level security (Postgres RLS), schema-per-tenant, tenant context middleware, data isolation testing, and migration strategies. Helps prevent cross-tenant data leaks.

8 stars

Best use case

multi-tenancy is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Multi-tenancy patterns for SaaS: row-level security (Postgres RLS), schema-per-tenant, tenant context middleware, data isolation testing, and migration strategies. Helps prevent cross-tenant data leaks.

Teams using multi-tenancy should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/multi-tenancy/SKILL.md --create-dirs "https://raw.githubusercontent.com/marvinrichter/clarc/main/skills/multi-tenancy/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/multi-tenancy/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How multi-tenancy Compares

Feature / Agentmulti-tenancyStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Multi-tenancy patterns for SaaS: row-level security (Postgres RLS), schema-per-tenant, tenant context middleware, data isolation testing, and migration strategies. Helps prevent cross-tenant data leaks.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# Multi-Tenancy Skill

SaaS products serve multiple customers on shared infrastructure. The most important property: tenant A can never see tenant B's data.

## When to Activate

- Building a B2B SaaS product
- Adding multi-organization support to an existing app
- Implementing Postgres Row-Level Security
- Testing tenant data isolation
- Migrating from single-tenant to multi-tenant architecture
- Choosing between shared-schema RLS, schema-per-tenant, or database-per-tenant isolation models based on compliance and cost requirements
- Writing cross-tenant isolation tests to verify that tenant A cannot read or write tenant B's data under any code path

---

## Isolation Models

```plantuml
@startuml
package "Shared Database" {
  package "Shared Schema\n(RLS)" {
    [tenant_id column on all tables]
    [Postgres Row-Level Security]
    note bottom: Lowest cost\nHighest density\nBest for most SaaS
  }
  package "Schema-per-Tenant" {
    [tenant_a schema]
    [tenant_b schema]
    note bottom: Medium cost\nEasy rollback per tenant\nHarder cross-tenant reporting
  }
}
package "Database-per-Tenant" {
  [Tenant A DB]
  [Tenant B DB]
  note bottom: Highest cost\nStrongest isolation\nFor compliance-heavy (HIPAA, FedRAMP)
}
@enduml
```

**Default choice:** Shared database + Row-Level Security. Only diverge for compliance requirements.

---

## Pattern 1: Row-Level Security (Postgres)

### Schema Setup

```sql
-- Every table has a tenant_id column
CREATE TABLE orders (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id),
    user_id     UUID NOT NULL,
    total       NUMERIC(12,2),
    created_at  TIMESTAMPTZ DEFAULT now()
);

-- Index for every tenant_id (performance critical)
CREATE INDEX idx_orders_tenant_id ON orders (tenant_id);

-- Enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

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

-- Grant access to app user (RLS applies to non-superusers)
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
```

### Setting Tenant Context

```typescript
// Set app.tenant_id before every query in the transaction
export async function withTenantContext<T>(
  tenantId: string,
  fn: (tx: Transaction) => Promise<T>
): Promise<T> {
  return db.transaction(async (tx) => {
    await tx.execute(sql`SET LOCAL app.tenant_id = ${tenantId}`);
    return fn(tx);
  });
}

// Middleware: extract tenant from JWT/session, set context
export async function tenantMiddleware(req, res, next) {
  const tenantId = req.user?.tenantId;
  if (!tenantId) return res.status(401).json(problem(401, 'No tenant context'));

  req.db = {
    // All queries in this request run within tenant context
    query: (fn) => withTenantContext(tenantId, fn),
  };
  next();
}

// Handler — no explicit tenant_id needed, RLS handles it
app.get('/api/v1/orders', tenantMiddleware, async (req, res) => {
  const orders = await req.db.query((tx) =>
    tx.select().from(ordersTable)  // RLS automatically filters to tenant
  );
  res.json({ data: orders });
});
```

### Testing Tenant Isolation

```typescript
it('cannot see another tenant\'s orders', async () => {
  const tenantA = await createTenant();
  const tenantB = await createTenant();

  const order = await withTenantContext(tenantA.id, (tx) =>
    tx.insert(orders).values({ tenantId: tenantA.id, total: 100 }).returning()
  );

  // Query as tenant B — should see no orders
  const result = await withTenantContext(tenantB.id, (tx) =>
    tx.select().from(orders)
  );

  expect(result).toHaveLength(0);
});

it('cannot insert into another tenant\'s data', async () => {
  const tenantA = await createTenant();
  const tenantB = await createTenant();

  // Try to insert order for tenant A while acting as tenant B
  await expect(
    withTenantContext(tenantB.id, (tx) =>
      tx.insert(orders).values({ tenantId: tenantA.id, total: 100 })
    )
  ).rejects.toThrow();  // RLS blocks the insert
});
```

---

## Pattern 2: Schema-per-Tenant

```typescript
// Migration per tenant
async function provisionTenant(tenantSlug: string) {
  const schema = `tenant_${tenantSlug}`;
  await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.identifier(schema)}`);

  // Run migrations in tenant schema
  await migrator.migrate({ schema });
}

// Query in tenant schema
function tenantDb(tenantSlug: string) {
  const schema = `tenant_${tenantSlug}`;
  return drizzle(pool, { schema: { ...tables }, logger: false })
    .withSearchPath(schema);  // All queries run in this schema
}
```

---

## Tenant Resolution

How do you know which tenant is making the request?

| Method | Example | Best For |
|--------|---------|---------|
| Subdomain | `acme.myapp.com` | B2B SaaS |
| Custom domain | `app.acme.com` | White-label |
| Path prefix | `/org/acme/dashboard` | Simple multi-user |
| JWT claim | `{ tenant: "acme-id" }` | API-first |
| API key lookup | key → tenant in DB | Server-to-server |

```typescript
// Subdomain resolution
function resolveTenant(req: Request): string {
  const host = req.hostname;  // e.g. "acme.myapp.com"
  const subdomain = host.split('.')[0];
  if (['www', 'app', 'api'].includes(subdomain)) {
    throw new Error('No tenant in subdomain');
  }
  return subdomain;
}
```

---

## Checklist

- [ ] Every table has `tenant_id` column with NOT NULL constraint
- [ ] Every `tenant_id` column has an index
- [ ] RLS enabled and policies created on all tenant-scoped tables
- [ ] Tenant context set before every query (not per-query `WHERE tenant_id =`)
- [ ] Tenant isolation tests exist (tenant A cannot read/write tenant B's data)
- [ ] No raw SQL queries that bypass the ORM and skip RLS
- [ ] Superuser role NOT used by application (RLS bypassed for superusers)
- [ ] Audit log tracks cross-tenant admin operations separately

Related Skills

multi-agent-patterns

8
from marvinrichter/clarc

Multi-Agent Systems: orchestration vs choreography, tool routing, state management, agent handoffs, parallelization (fan-out/fan-in), error handling in multi-agent workflows, Claude SDK patterns (Agent/Tool/Handoff), and observability with OpenTelemetry.

multi-agent-patterns-advanced

8
from marvinrichter/clarc

Advanced multi-agent patterns — capability registry, durable state (Redis/DynamoDB), task decomposition, testing multi-agent systems, pattern quick-selection guide, failure handling, cost management, and worktree isolation. Extends multi-agent-patterns.

zero-trust-patterns

8
from marvinrichter/clarc

Zero-Trust security patterns — mTLS between microservices (Istio/SPIFFE), SPIRE workload identity, OPA/Envoy authorization, NetworkPolicy default-deny-all, short-lived credentials, service mesh security, and Kubernetes RBAC hardening.

wireframing

8
from marvinrichter/clarc

Wireframing and prototyping workflow: fidelity levels (lo-fi sketch → mid-fi wireframe → hi-fi prototype), tool selection (Figma, Excalidraw, Balsamiq), user flow diagrams, wireframe annotation standards, information architecture (IA) mapping, and the handoff from wireframe to visual design. For developers who need to communicate UI structure before writing code.

webrtc-patterns

8
from marvinrichter/clarc

WebRTC patterns — peer connection setup, ICE/STUN/TURN configuration, signaling server design, SFU vs mesh topology, screen sharing, media track management, and reconnect/ICE restart handling.

webhook-patterns

8
from marvinrichter/clarc

Webhook patterns for receiving, verifying (HMAC), and idempotently processing third-party events. Covers Stripe, GitHub, and generic webhook patterns, delivery guarantees, retry handling, and testing.

web-performance

8
from marvinrichter/clarc

Web performance optimization: Core Web Vitals (LCP, CLS, INP), Lighthouse CI with budget configuration, bundle analysis (webpack-bundle-analyzer, vite-bundle-visualizer), hydration performance, network waterfall reading, image optimization (WebP/AVIF, srcset), and font performance.

wasm-performance

8
from marvinrichter/clarc

WebAssembly performance: wasm-opt binary optimization, size reduction (panic=abort, LTO, strip), profiling WASM in Chrome DevTools, memory management (linear memory, avoiding GC pressure), SIMD, and multi-threading with SharedArrayBuffer.

wasm-patterns

8
from marvinrichter/clarc

WebAssembly patterns: wasm-pack, wasm-bindgen (JS↔Wasm interop), WASI, Component Model, wasm-opt, Rust-to-WASM compilation, JS integration (web workers, streaming instantiation), and production deployment (CDN, Content-Type headers).

visual-testing

8
from marvinrichter/clarc

Visual Regression Testing: tool comparison (Chromatic/Percy/Playwright screenshots/BackstopJS), pixel-diff vs AI-based comparison, baseline management, flakiness strategies (masks, tolerances, waitForLoadState), CI integration with GitHub Actions, and Storybook integration.

visual-identity

8
from marvinrichter/clarc

Brand identity development: color palette construction (primary/secondary/semantic/neutral), logo concept brief writing, typeface pairings, brand voice definition, mood board direction, and Brand Guidelines document structure. Use when establishing or evolving a visual brand — not for implementing existing tokens.

ux-micro-patterns

8
from marvinrichter/clarc

UX micro-patterns for every product state: Empty States, Loading States (skeleton screens, spinners, optimistic UI), Error States, Success States, Confirmation Dialogs, Onboarding Flows, and Progressive Disclosure. These patterns apply to every feature — done wrong, they're the biggest source of user confusion.