← Architecture
Data Keep customers apart

Multi-tenant data isolation

Decide where the tenant filter lives.

If you have switched workspaces in Slack, Notion, or Linear, you have used a multi-tenant system: one product, many companies, and a slug in the URL saying whose data you are looking at. Behind the switcher, something has to make sure the slug is the only thing that changes. Let’s give a hiring tool four places to put that check, and probe each one the way a later change would.

TypeScriptGoOne hiring tool, four placements, a leak probe, two recorded builds.

01 / The prompt

“Build a hiring tool that many small companies can share.”

Juniper, a dental practice, and Kestrel, a bike shop, both hire through the same tool. Each has a workspace with its candidates and interview notes, and the notes say things like “asks for $120k” and “not a fit”. An agent given the request will answer it one of three ways, and all three work in the demo:

  • A tenant_id on every table, and every query filtered by it.
  • The same column, with row-level security: the database itself hides other companies’ rows.
  • A database per company, chosen per request.

Each keeps the handlers you review apart. What differs is the handler nobody reviews: an export added in a hurry, a table added next month, a background job, a connection change to fix a permission error. In the lesson’s example, a notes export written without the filter hands Juniper 1 of Kestrel’s notes when the filter lives only in the queries.

The brief never answered: when a later change forgets the tenant, which layer still holds, and does it fail by showing nothing or by showing everything?

This is not hypothetical. CVE-2025-48757 records that “an insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites” (CVE record, published 30 May 2025, fetched 23 September 2026). The supplier disputes it, noting that each customer is responsible for protecting their own application’s data, which is this lesson’s point: the placement is yours to choose and to check.

02 / Name the choice

Three places for the tenant filter, and each misses something different.

A tenant is one customer company, and its tenant context is the company a request acts for, taken from the signed-in user’s membership. The filter can live in the query layer (every query binds the tenant), the row layer (the database applies a policy to every row, which PostgreSQL calls row-level security), or the database layer (each company has its own database).

Take the tenant from the verified membership, never from the request. Put it in two layers that fail differently, so one forgotten filter is not a leak. Give a company its own database when it needs its data, its load, or its restores kept apart, and pay for it in migrations.

What each placement covers in the hiring tool, and what it costs
Query layerRow policyBothDatabase per company
A handler without the filterLeaksCoveredCoveredCovered
A table added without a policyCovered, if it filtersLeaksCoveredCovered
Context that outlives the requestRefusesLeaks with SET; refuses with SET LOCALRefusesLeaks if the connection is kept
A tenant taken from the URLLeaks everywhere: placement does not check identity
Adding a columnOne migrationOne per company, and some can fail
One company’s big importEveryone waitsOnly that company waits
Restore one company to yesterdayHard: its rows are mixed with everyone’sRestore one database

Words to put in a prompt or a review

Tenant
One customer company, with its own users and data inside a shared product.
Tenant context
The company a request acts for, from the user’s verified membership.
Row-level security
A database rule that filters every row by a policy, whatever the query says.
Policy
One table’s row rule, such as “tenant_id equals this connection’s tenant”.
Bypass
A role that skips policies: superusers, BYPASSRLS roles, and usually table owners.
Database per tenant
Each company in its own database; also called a silo. A schema per company is the middle ground.
What PostgreSQL’s row-level security does and does not coverFrom the PostgreSQL 18 documentation

Once a table has row security enabled, “if no policy exists for the table, a default-deny policy is used”, so a forgotten policy on an enabled table shows nothing. A table that was never enabled has no row security at all, which is the case in the story. And some roles skip it: “Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table. Table owners normally bypass row security as well”, unless the table uses FORCE ROW LEVEL SECURITY. Finally, “Referential integrity checks … always bypass row security” (Row Security Policies, fetched 23 September 2026), so a foreign key alone will let Juniper point at Kestrel’s row; include tenant_id in the key.

A schema per company sits between the shared tables and a database per company: one database, separate tables, a search_path per request. It shares the database layer’s leftover-context risk and its per-company migrations, and not its separate load or restores.

03 / Follow one request

Juniper’s recruiter sends five requests. Which placements hold?

Each card runs the lesson’s leak probe against one placement. The first request is the one everyone reviewed; the next four are what later changes add. Then open Try it, pick a placement and a request, and turn on Kestrel’s import.

Multi-tenant data isolation

One recruiter, five requests, four placements

Query layer

Juniper’s request is on its way…

Row policy

Juniper’s request is on its way…

Query layer and row policy

Juniper’s request is on its way…

A database per company

Juniper’s request is on its way…

01/ 05
The reviewed handlers

Juniper’s recruiter lists candidates.

The handlers everyone reviewed. Every placement answers with Juniper’s two candidates. A demo stops here.

Reduced motion: choose a scene to see its completed state.

Read this scene

The handlers everyone reviewed. Every placement answers with Juniper’s two candidates. A demo stops here.

Query layer: Only Juniper’s rows.

Row policy: Only Juniper’s rows.

Query layer and row policy: Only Juniper’s rows.

A database per company: Only Juniper’s rows.

Watch restarts the story when you come back. Step through shows where each chapter ends. Try it runs the probe for the placement and the request you pick.

04 / Read the shape

A repository, a row policy, and a database chosen per request.

Each tab is one of the placements. Basic form is the query layer, In the wild is the row layer, and At the call site is the database layer. Notice where each one’s tenant comes from, and how long it lives.

The query layer: a repository that binds the tenant as a parameter on every query and refuses a query with none. A handler that reads the table without it is not covered.

TypeScriptReading
designs.ts
/**
 * The query layer: a repository that binds the tenant as a parameter on every
 * query. It refuses a query with no tenant. It cannot help a handler that
 * reads the table without going through it.
 */
export class TenantRepository {
	conn: Connection;
	constructor(conn: Connection) {
		this.conn = conn;
	}
	read(table: Table, tenant: Tenant | null): Row[] {
		if (tenant === null) throw new NoTenant('no tenant for this query');
		return this.conn.select(table, { tenant_id: tenant });
	}
}
GoAlongside
designs.go
// TenantRepository is the query layer: it binds the tenant as a parameter on
// every query and refuses a query with no tenant. It cannot help a handler that
// reads the table without going through it.
type TenantRepository struct{ Conn *Connection }

func (r TenantRepository) Read(table, tenant string) ([]Row, error) {
	if tenant == "" {
		return nil, ErrNoTenant
	}
	return r.Conn.Select(table, tenant), nil
}
The behavior these examples promiseChecked by 24 shared cases
  • Every placement answers the reviewed handler with Juniper’s rows only.
  • The query layer alone leaks through a handler that skips the filter; the row layer alone leaks through a table with no policy; the database layer and a session-wide row setting leak when the request’s context outlives it. A repository handed no tenant refuses.
  • Both layers together cover every probe but one.
  • A tenant taken from the URL leaks in every placement: none of them checks who the user is.

Every expectation was generated by a separate model written from the contract in the examples’ README, not copied from either implementation, and it is kept beside the examples. The example models a database; it is not PostgreSQL.

Reading the TypeScriptOne Scope, four placements

Every placement implements Scope: setUp at the start of a request, then read. The tenant in a where is always a bound value compared with a row’s field, never text pasted into a query; the lesson’s old template built SQL by concatenating the tenant, and this one never does.

Reading the GoAn interface and a sentinel error

The placements satisfy one Scope interface. A repository with no tenant returns ErrNoTenant, and the probe reports it as a refusal rather than an empty list, because “nothing” and “not allowed” should look different in a log.

Run it yourselfNo dependencies

Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. Both print:

query: unfiltered-handler leaked 1, unprotected-table ok, leftover-context refused, tenant-from-url leaked 1
row: unfiltered-handler ok, unprotected-table leaked 1, leftover-context leaked 1, tenant-from-url leaked 1
query+row: unfiltered-handler ok, unprotected-table ok, leftover-context refused, tenant-from-url leaked 1
database: unfiltered-handler ok, unprotected-table ok, leftover-context leaked 1, tenant-from-url leaked 1

05 / Review the agent’s diff

“I pointed the app at the migrations connection.”

The hiring tool runs on PostgreSQL with row-level security, and the migrations role owns every table. A new offers page failed with a permission error, and an agent fixed it.

The agent’s pull request

“The new offers page failed with ‘permission denied for table offers’. I pointed the app at the migrations connection, which has the right permissions. Offers load now, and all tests pass.”

// db.ts
			(removed) const pool = new Pool({ connectionString: process.env.APP_DATABASE_URL });
			(added) const pool = new Pool({ connectionString: process.env.MIGRATIONS_DATABASE_URL });
			// routes/offers.ts
			(added) const { rows } = await pool.query('SELECT * FROM offers');
			
The tests sign in as one company at a time. What do you do with this change?

06 / How it fails

A tenant boundary fails open or closed. Choose closed.

The first five rows are shared cases the tests run; the last three are not modeled.

Failure modes of keeping companies apart in the hiring tool
What happensWhat Juniper seesWhat holds it
A handler reads without the filterKestrel’s notes, with the query layer aloneA row policy or a separate database
A table is added without a policyKestrel’s offers, with the row layer aloneThe query filter, or a separate database
The tenant outlives the requestKestrel’s candidates, from a session-wide setting or a kept connectionSET LOCAL per transaction; a repository that must be handed the tenant
The tenant comes from the URLKestrel’s candidates, everywhereA membership check before any placement
Kestrel imports 5,000 candidatesSlow pages in a shared databaseA per-company limit, or a database of its own
The app connects as the table ownerEverythingA separate app role, or FORCE ROW LEVEL SECURITY. Not modeled.
A migration fails on some company databasesErrors on some workspaces onlyMigrations that can run twice, and a per-database version check. Not modeled.
A cache key without the tenantJuniper’s cached list under Kestrel’s nameThe tenant in every cache key; see the frontend row. Not modeled.

A refusal is a bug report; a leak is an incident. That is why the probe counts a refused request as passing. Validation at the edge covers why the tenant must come from what the server verified, not from what the client sent.

07 / Is it worth it?

A second layer costs a policy per table. A database per company costs a fleet.

The shared four changes, across the placements
ChangeQuery layerQuery layer and row policyDatabase per company
A second entry point: a nightly job that emails each company a digestIt must remember the filter, per company.It must set the tenant per company; without it, it reads nothing.It opens each company’s database in turn.
Replacing the databasePortable: the filter is in the code.Row-level security is database-specific; the policies move or are lost.Portable, times the number of companies.
A new rule: agency recruiters work for several companiesNo difference: membership already decides the tenant per request.
A data team wants hiring numbers across companiesOne query as an audited reporting role that may bypass the policy.One query per database, or a warehouse fed from each.

The costs: a policy and a grant in every migration that adds a table, a transaction around every request to hold SET LOCAL, and a reporting role you must audit. A database per company adds a migration per company, connection routing, and per-company backups; it buys separate load, restores, and a boundary a stray query cannot cross.

Measure before and after:

  • Rows from another tenant in the probe, per route, on every change. The accepted result is zero, and a refusal counts as zero.
  • Rows returned whose tenant is not the session’s, counted at runtime by the repository; any non-zero value is an incident.
  • p95 latency for small companies while a large one imports, and migration time across all databases.

This lesson did not measure a real product, and gives no numbers.

08 / Ask for it

One brief, two prompts, then one ticket.

Two agents running Claude Sonnet each got the brief from section 01. One prompt added a Tenant isolation block asking for the database layer: a SQLite file per workspace, opened only after the membership check and never kept between requests. Then each build went to a fresh agent with a ticket for offers and a notes export, the new route and new table this lesson is about. A script signed in as Juniper’s recruiter and probed every route for a marked secret in Kestrel’s notes, and did the same to a control build we wrote with one unfiltered export.

What the checker found, run 2026-09-23
QuestionPlain promptTenant-isolation promptControl (not an agent)
Six probes of the routes in the briefNo leak; 403, 403, 403 for Kestrel’s workspaceNo leak; 403, 403, 403 for Kestrel’s workspaceNo leak; 403, 403, 403 for Kestrel’s workspace
After the ticket: export, offers, an offer through JuniperNo leak; the offer through Juniper answered 404No leak; the offer through Juniper answered 404The export shows Kestrel’s interview note
The export with its tenant condition removedShows Kestrel’s interview noteNothing to remove; no leakNot asked
Its own tests19 of 19; after the ticket 33 of 3317 of 17; after the ticket 34 of 34None

Neither build leaked, before or after the ticket. The plain agent’s brief said “a company must never see … another company’s candidates or notes”, and it wrote a workspace condition into every query; the agent who picked up the ticket followed that pattern into the new export, down to matching the join on the workspace:

server.ts · plain prompt, after the ticket
const exportNotes = db.prepare(
  `SELECT candidates.name AS candidate, notes.author AS author, notes.body AS body
   FROM notes
   JOIN candidates ON candidates.id = notes.candidate_id AND candidates.workspace = notes.workspace
   WHERE notes.workspace = ?
   ORDER BY notes.created_at ASC`
);

The files agent’s export has no tenant condition at all, and needs none: the connection it was handed is Juniper’s file.

src/app.ts · tenant-isolation prompt, after the ticket
function handleExportNotes(res: ServerResponse, db: DatabaseSync): void {
  const rows = db
    .prepare(
      `SELECT candidates.name AS candidate, notes.author AS author, notes.body AS body
       FROM notes
       JOIN candidates ON candidates.id = notes.candidate_id
       ORDER BY notes.rowid ASC`,
    )
    .all() as unknown as ExportNoteRow[];

So the checker asked the question this lesson is about: what happens when the next person forgets? It took the workspace condition out of the plain build’s export and probed again, and Juniper’s recruiter got Kestrel’s interview note with the salary in it. There was nothing to take out of the files build. Both builds have exactly one layer, and the plain build’s one layer is a line every future query must remember.

The missing line is the second layer: isolate below the handler as well as in it, so a query that forgets the tenant returns nothing instead of everything. With PostgreSQL that is row-level security on every tenant table; with a database per company it is the connection itself. The product sentence got a correct build. It did not get one that survives the next person.

How the runs were made and checkedFour builds, two rounds, recorded as written
  • Each pair of agents was launched at the same time; no agent was told about the other, the lesson, or the checker. Round two started from byte-for-byte copies of round one.
  • All four builds are kept with checksums. The checker writes its own users file and restores each build into a fresh folder. The edit that removes the plain export’s condition is written out in the checker, not hidden.
  • The checker’s first run did not yet ask the forgotten-condition question; its other answers were the same.
  • Three agents wrote scratch files to /tmp against the prompt; one left an empty file, which we deleted, and one left a hung shell, which we stopped by its process id. No agent stopped a process by name or pattern.
  • One run of each prompt is a sample, not a measurement of the model.

09 / Hold it there

Every new route and table is a new chance to forget. Probe them all.

  1. Let the database refuse

    In PostgreSQL, enable row security in the migration that creates the table, connect the app as a role that owns nothing, and set the tenant with SET LOCAL inside each request’s transaction. The documentation’s own words are the checklist: owners and BYPASSRLS roles skip policies, and an enabled table with no policy denies everything.

  2. A rule a check enforces

    Only the repository may run a query against a tenant table. An import rule, the way Enforcement layer enforces one, turns that sentence into a failing build; Architecture as rules covers writing it. This lesson did not run such a rule on the recorded builds.

  3. Probe what ships

    The probe is the check that matters, because it tests behavior rather than code shape: sign in as one company, call every route with the other company’s ids and slug, and fail on any row that comes back. The checker in section 08 does exactly this against the recorded builds:

    check-runs.mjs
    const byId = await ana('GET', `/w/juniper/candidates/${ids.kestrel}`);
    const noteById = await ana('POST', `/w/juniper/candidates/${ids.kestrel}/notes`, { body: 'planted by Ana' });
    const kestrelView = await s.call('tok-kai', 'GET', `/w/kestrel/candidates/${ids.kestrel}`);
    q["Ana uses Kestrel's candidate id in her own workspace"] = {
    	statuses: [byId.status, noteById.status],
    	leaked: shows(byId, ids),
    	wroteIntoKestrel: kestrelView.text.includes('planted by Ana')
    };
Where this lives in React and SvelteThe workspace is already in your URL and your cache keys. Switching company is where it leaks.

Where it already is in your components

Any app with a workspace switcher has the tenant in its routes, /w/juniper/candidates, and should have it in its cache keys. A TanStack Query key of ['w', workspace, 'candidates'] makes Juniper’s list and Kestrel’s list two entries; a SvelteKit load keyed by params.workspace reruns when it changes.

When you have to own it

The switcher. A cache keyed only by 'candidates' shows Juniper’s list under Kestrel’s name until the refetch lands, and a shared laptop keeps Juniper’s notes in memory after the recruiter moves on. Key everything by workspace, and remove the old workspace’s entries on a switch. The server’s membership check is still the boundary; the client only avoids showing what it should have dropped.

The candidate list, keyed by workspace. React uses a TanStack Query key with the workspace in it; SvelteKit loads by the route parameter.

ReactAlready in your code
Candidates.tsx
import { useQuery } from '@tanstack/react-query';

type Candidate = { id: string; name: string };

// The workspace is already in two places: the route (/w/juniper/candidates),
// which passes it in, and the cache key. Keying the query by workspace means Juniper's list and
// Kestrel's list are two cache entries, never one.
export function Candidates({ workspace }: { workspace: string }) {
	const candidates = useQuery({
		queryKey: ['w', workspace, 'candidates'],
		queryFn: async (): Promise<Candidate[]> => {
			const response = await fetch(`/w/${workspace}/candidates`);
			if (!response.ok) throw new Error(`candidates: ${response.status}`);
			return (await response.json()).candidates;
		}
	});
	if (candidates.isPending) return <p>Loading candidates…</p>;
	if (candidates.isError) return <p>Could not load candidates.</p>;
	return (
		<ul>
			{candidates.data.map((c) => (
				<li key={c.id}>{c.name}</li>
			))}
		</ul>
	);
}

10 / Make the call

Two layers for everyone; a database of its own for whoever needs it.

For the hiring tool I would keep every company in shared tables with a tenant_id, filter every query through a repository that binds it, and enable row-level security on every tenant table as a second layer, with the tenant set per transaction and the app on a role that owns nothing. I accept a policy per table, a transaction per request, and an audited reporting role. I would give a company its own database when it asks for its data to be kept apart by contract, when its load starts to slow everyone else, or when it needs its own restores, and I would reconsider the shared design if that becomes most companies.

A query filter alone is fine for a prototype with a handful of handlers written by one person. The day a second person, or an agent, adds a route, add the second layer.

Keep a note of the decision

Why
Many small companies share one hiring tool; interview notes hold salaries and hiring decisions; routes and tables will be added by people and agents who never saw the first handler.
What
Shared tables with tenant_id, a repository that binds it, and row-level security on every tenant table; the tenant from the verified membership.
Constraint
Each layer misses something different, so one forgotten filter or policy is not a leak; a refusal is better than a leak.
Fallback
A policy and grant in every migration, SET LOCAL in every request, an audited bypass role for reports; one company’s import can slow the others.
Reconsider when
A company needs its data, its load, or its restores kept apart; then that company gets a database of its own.

Take it with you

Explain it without saying “multi-tenancy”: “Many companies use our app, and each must only ever see its own things. We check which company you belong to when you arrive, and then two different parts of the system each refuse to hand over another company’s things, so if one of them is forgotten, the other still says no.” Then pick the newest route in your own app and ask what it returns when you call it with another customer’s id.

Paste into your next prompt, and fill in the blanks

Every table that holds [a company]'s data has tenant_id NOT NULL, and every reference includes it.
The tenant comes from the verified session's membership, never from the URL, the body, or a header the client sets.
Two layers, so one mistake is not a leak:
- every query goes through [a repository] that binds tenant_id as a parameter and refuses to run without one;
- [PostgreSQL] row-level security on every tenant table, enabled in the same migration that creates it, with the tenant set per transaction (SET LOCAL app.tenant) and the app connecting as a role that does not own the tables.
A test signs in as one company and calls every route, including new ones, and fails on any row from another company.
[Company X] gets its own database only if [it needs its data, load, or restores kept apart], and migrations run for every database.
Connections to follow nextRelated lessons

Take the hiring tool into your editor. Add an offers table the way the next ticket would, then run the probe before and after writing its policy.

Back to architecture →