Multi-Tenancy skill for Codex

One customer never sees another's data. Codex picks an isolation model from your tenant count and contracts, then makes tenant scope structural: composite keys that include tenant_id, Postgres row-level security set per transaction so pooled connections stay safe, and a request context that follows work into caches, queues, file paths and search. It finishes with a generated cross-tenant leak test over every route.

  • Backend & APIs
  • $multi-tenancy
  • 146 lines
  • 2 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- multi-tenancy

Installs to ~/.agents/skills/multi-tenancy/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $multi-tenancy, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and retrofits tenant isolation for SaaS backends - shared-schema tenant_id, schema-per-tenant and database-per-tenant models, tenant resolution from subdomain or token, Postgres row-level security with SET LOCAL under connection pooling, tenant-scoped unique keys and indexes, tenant-aware caches, jobs, files and search, and cross-tenant leak tests. Use when building a multi-tenant SaaS, adding organizations or workspaces, auditing for data leaking between customers, or moving a big tenant to its own database.

Use it when

  • Building a SaaS where several customers share infrastructure
  • Adding organizations, workspaces or accounts to an existing app
  • A bug or audit suggests data can leak between customers
  • A large customer needs dedicated storage or data residency

Use something else when

How to check the result

A parametrised test logs in as tenant A and requests every route with tenant B's IDs, expecting 403 or 404, and the app role sees zero rows when no tenant is set.

The mistake it prevents

Setting the tenant with a session-level SET on a pooled connection, so the next request on that connection inherits the previous customer's tenant.

~/.agents/skills/multi-tenancy/SKILL.md

--- name: multi-tenancy description: Designs and retrofits tenant isolation for SaaS backends - shared-schema tenant_id, schema-per-tenant and database-per-tenant models, tenant… license: MIT ---

Done well means: every read and write is scoped to a tenant by construction, not by remembering to add a WHERE; the scope travels with the request into caches, jobs, files and search; and an automated test proves tenant A cannot see tenant B through any endpoint.

Choose the isolation model

ModelUse whenCosts
Shared schema, tenant_id column (default)Many small or medium tenants, one codebase, fast onboardingEvery query must be scoped; noisy neighbours share resources
Shared schema + Postgres RLSSame, plus defence in depth against missed filtersPooling and migration care (below)
Schema per tenantTens to low thousands of tenants, per-tenant customisation or restoreMigrations run N times; catalog bloat past a few thousand schemas
Database per tenantRegulatory isolation, data residency, very large tenants, per-tenant backup/restoreFleet management, connection count, cross-tenant reporting needs a warehouse
HybridMost tenants shared, a few enterprise tenants dedicatedRouting layer that maps tenant to database

Start shared + RLS unless a contract or regulator says otherwise. Moving one tenant out to a dedicated database later is easier than merging hundreds of schemas back.

Before changing anything

  1. Read AGENTS.md and the data-access layer (ORM base repository, query helpers).
  2. List every table and classify it: tenant-owned, global (plans, feature catalog), or user-level spanning tenants (a user in several orgs). rg -n "tenant|org_id|account_id|workspace".
  3. Find every non-HTTP entry point: job workers, cron, webhooks, CLI scripts, admin tools. These are where tenant scope usually gets lost.

Schema rules (shared model)

CREATE TABLE invoices (
  tenant_id  uuid NOT NULL REFERENCES tenants(id),
  id         uuid NOT NULL DEFAULT gen_random_uuid(),
  customer_id uuid NOT NULL,
  number     text NOT NULL,
  PRIMARY KEY (tenant_id, id),
  UNIQUE (tenant_id, number),                                   -- uniqueness is per tenant
  FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)  -- cannot point across tenants
);
CREATE INDEX ON invoices (tenant_id, created_at DESC);           -- tenant_id leads every index
  • tenant_id NOT NULL on every tenant-owned table, including join tables.
  • Composite foreign keys that include tenant_id make cross-tenant references impossible.
  • Globally unique columns (email for login) live on global tables only.

Postgres row-level security

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;      -- applies to the table owner too
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

Set the tenant per transaction, never per connection, because pooled connections are reused:

await db.transaction(async (tx) => {
  await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`); // true = transaction-local
  return handler(tx);
});
  • The app role must not be a superuser, must not own the tables (or tables must use FORCE), and must not have BYPASSRLS. Migrations and admin jobs use a separate role.
  • With current_setting(..., true) an unset tenant yields NULL and matches no rows: fail closed.
  • PgBouncer in transaction mode is safe with set_config(..., true); session-level SET is not.
  • Check plans: the policy predicate must use the tenant_id index. Run EXPLAIN as the app role.

For policy design in depth, use postgres-row-level-security.

Tenant resolution

SourceNotes
Subdomain acme.app.comMap host to tenant in a cached lookup; reject unknown hosts
Path /t/acme/...Easy for APIs; still verify membership
Token claim tenant_id / org_idSet by your auth server after membership check
Header X-Tenant-IDOnly as a selector among tenants the user already belongs to

Whatever the source, verify membership: user_id belongs to tenant_id with a role. A tenant ID from the client is a request, not a fact. Resolve once in middleware and put it in a request context object (AsyncLocalStorage, contextvars, request-scoped DI), not a global.

Scope everything else

SurfaceRule
CachesKey prefix t:{tenant_id}:...; never cache tenant data under a global key
Background jobsPayload carries tenant_id; worker sets context (and RLS setting) before any query
Cron / batchIterate tenants explicitly; one failing tenant must not abort the rest
File storagePrefix tenants/{tenant_id}/...; presigned URLs generated only after a scoped lookup
Search indexesFilter by tenant_id on the server or with scoped keys; never trust a client filter
Logs and tracesAdd tenant_id as a field for debugging and per-tenant usage
Rate limits and quotasPer tenant, so one customer cannot starve others (api-rate-limiting)
Analytics exportsExplicit tenant filter; review any query without one

Retrofitting tenancy into a single-tenant app

  1. Add tenants table and nullable tenant_id columns; backfill with the default tenant (data-backfills), then set NOT NULL and add composite keys (zero-downtime-migrations).
  2. Introduce the request context and a scoped repository layer; replace raw queries.
  3. Add RLS in permissive audit mode first: log queries without tenant context, fix them, then enforce.
  4. Run the leak test suite (below) before onboarding a second real tenant.

Production migrations and backfills require explicit user confirmation and a tested rollback.

Leak tests

Create two tenants with identical fixtures, then for every endpoint and job:

@pytest.mark.parametrize("route", ALL_TENANT_ROUTES)
def test_no_cross_tenant_access(client, tenant_a_user, tenant_b_resource_ids, route):
    resp = client.as_user(tenant_a_user).get(route.format(id=tenant_b_resource_ids[route]))
    assert resp.status_code in (403, 404)
  • Generate the route list from the router so new endpoints are covered automatically.
  • Add a DB-level test: connect as the app role without setting app.tenant_id and assert every tenant table returns zero rows.
  • Prefer 404 over 403 for other tenants’ resources so IDs are not confirmed to exist.

Gotchas

  • Admin “impersonate tenant” tools that bypass scoping become the leak. Log every use.
  • SELECT DISTINCT / aggregate endpoints (counts, dashboards) often forget the tenant filter.
  • Sequential integer IDs leak tenant sizes and invite enumeration; use UUIDs or ULIDs.
  • ORMs’ global scopes (Rails default_scope, Django managers, Prisma extensions) are bypassed by raw SQL and some bulk operations. RLS catches what they miss.
  • Database-per-tenant multiplies connections; pool per tenant with small limits (connection-pooling).
  • Deleting a tenant (offboarding) must cover DB rows, files, search docs, cache keys and backups policy. Never run it without explicit confirmation.

Output

Report: chosen model and why, tables classified, how tenant context is set and propagated, RLS status per table, surfaces scoped (cache, jobs, files, search), and leak-test coverage.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Multi-Tenancy"
  short_description: "One customer never sees another's data"
  default_prompt: "$multi-tenancy Turn this single-tenant app into a multi-tenant SaaS with organizations"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem