Playbook library

Multi-tenant data isolation

Tenant isolation is the property that is never visible in manual testing: everything looks right when you are logged into one account. It also degrades quietly — a new query without the filter, a cache key without the tenant prefix, a worker that inherited the wrong context. These rules exist to make each of those a diff-level failure.

Written for
SaaS teams where one database serves many customers — including agencies running client workspaces.
Not covered
Row-level security in the database is a stronger guarantee than any of this and is worth adopting if you can. These rules are what protects the code paths that RLS does not cover, and what you need in the meantime.

10 rules7 blocking3 warning

Start a free playbook →

01 The tenant id comes from the session

error

The tenant/organization id is read from the authenticated session or a verified token, and from nowhere else. Taking it from the request body, the query string, a path parameter or a client-supplied header is rejected, even when the code also checks membership afterwards. Any tenant id the client can send is a tenant id the client can change.

Blocks: this is the root of the entire isolation model — one exception voids all the others.

Scopewhole repository

Tagsmulti-tenancysecurityauthorization

02 Every query filters on the tenant

error

Every select, update and delete against a tenant-scoped table includes the tenant predicate — including lookups by primary key. "The id is a UUID so it's unguessable" is not an authorization check: ids leak through exports, logs, support tickets and shared URLs.

Blocks: a single missing predicate is a cross-customer data leak.

Scopewhole repository

Tagsmulti-tenancysecuritydatabase

03 Cache keys carry the tenant id

error

Any cache key derived from an entity id, a user input, or a computed result includes the tenant id. A key without it serves one customer's data to another, and the poisoned entry outlives the deploy that introduced the bug — so the fix does not fix it.

Blocks: cache leaks are cross-customer, persistent, and invisible in logs.

Scopewhole repository

Tagsmulti-tenancysecuritycaching

04 Storage paths are tenant-prefixed

error

Uploads and generated files are written under a tenant-scoped prefix, and any signed URL is minted with a short expiry (15 minutes or less). A flat bucket namespace means a guessable or enumerable key is a direct read of another customer's document, with no application code involved.

Blocks: object storage bypasses every check that lives in your application.

Scopesrc/**/storage/**src/**/*upload*src/**/*file*

Tagsmulti-tenancysecuritystorage

05 Jobs carry the tenant explicitly

error

A queued job or scheduled task includes the tenant id in its payload, and the handler re-establishes the scope from it before touching data. Relying on ambient or thread-local context inside a worker is rejected — the context that produced the job is long gone by the time it runs.

Blocks: a worker with the wrong scope writes to the wrong customer, silently.

Scopesrc/**/jobs/**src/**/workers/**src/**/*worker*

Tagsmulti-tenancysecurityqueue

06 New tenant-scoped tables carry the column and the index

error

A new table holding customer data declares a non-null tenant column with a foreign key to the tenant table, and an index leading with that column. Adding it later means a migration on a populated table plus an audit of every query written in the meantime.

Blocks: the cost of getting this wrong grows every day the table exists.

Scopemigrations/**db/migrations/****/schema*.ts

Tagsmulti-tenancydatabase

07 Role grants verify the target tenant

error

Before creating an invitation or changing a role, verify that the actor is a member of the session's tenant with permission to grant, and that the target user and target role belong to that same tenant. Cross-tenant privilege escalation is the highest-severity bug this product category has, and the code path looks entirely ordinary.

Blocks: it grants an attacker durable, legitimate-looking access to another customer.

Scopesrc/**/members/**src/**/invitations/**src/**/*role*

Tagsmulti-tenancysecurityauthorization

08 Cross-tenant reads are explicit and audited

warning

A query that intentionally spans tenants — admin tooling, billing rollups, internal analytics — goes through a named unscoped helper and logs the actor who ran it. Without a marked escape hatch, people quietly drop the filter in ordinary code and the two cases become indistinguishable in review.

Warns: legitimate uses exist, so the goal is visibility — the reviewer confirms the escape hatch was intended.

Scopewhole repository

Tagsmulti-tenancyaudit

09 Foreign resources return 404, not 403

warning

A resource belonging to another tenant returns exactly what a nonexistent resource returns. A 403 confirms the id exists, which turns any endpoint into an enumeration oracle for another customer's data volume, naming scheme and activity.

Warns: it leaks metadata rather than content, but it is what makes targeted attacks cheap.

Scopesrc/routes/**apps/api/**

Tagsmulti-tenancysecurityapi

10 Ship the cross-tenant test with the endpoint

warning

Every new tenant-scoped endpoint ships a test asserting that a member of tenant B receives a 404 on tenant A's resource. Isolation is the property manual testing never exercises, because a human is only ever logged into one account at a time.

Warns: missing tests do not break production today — they just guarantee nobody notices when it does.

Scope**/*.test.***/*.spec.*tests/**

Tagsmulti-tenancytesting

Before you import all of it

Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.

Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.

Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.

The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.

Your agents already code fast. Make them code like your team.

Start free — no card required