SaaS architecture
Drizzle and PostgreSQL row-level security, measured
PostgreSQL row-level security with Drizzle ORM 0.45, measured: setting the tenant per request, the documented wrapper, and what drizzle-kit leaves out.
Drizzle can declare row-level security in the schema — pgPolicy(…) and .enableRLS() — and drizzle-kit turns that into a migration. The policy still needs to know the tenant at runtime, and the usual way to tell it is a setting on the connection: set_config('app.tenant', …). Where you put that line decides whether a request sees its own rows, no rows, or another customer’s rows.
We measured Drizzle ORM 0.45 on node-postgres. A tenant set with db.execute before the query leaked into later requests, and under concurrent load requests that did set their own tenant got another customer’s invoices in about half the cases. A db.transaction that sets the tenant first held every time. Two more things came out of the documentation itself: the migration drizzle-kit generates enables row-level security but does not force it, so the role that owns the table sees every row; and the RLS wrapper on Drizzle’s documentation page, copied as written, replaces the real database error with “current transaction is aborted” and breaks on a single quote in a token claim.
The setup
drizzle-orm 0.45.3 and drizzle-kit 0.31.11 with node-postgres 8.23.1 (pool size 10), Node 22.23; PostgreSQL 17.10 in a throwaway container. Measured on 4 October 2026.
- One table
invoices, 20 rows for tenant 1 and 10 for tenant 2, row-level security forced, policyusing (tenant_id = nullif(current_setting('app.tenant', true), '')::int), a check constraintamount > 0. Drizzle connects as an ordinary role, not the table owner. - A “request” sets the tenant, if it has one, and runs
db.select({ n: count() }).from(invoices). Four requests in a row: tenant 1, no tenant, tenant 2, no tenant. “No tenant” stands for any path that does not set one — a public page, a health check, a handler someone forgot.
What did each request see?
| How the tenant is set | Tenant 1 | No tenant | Tenant 2 | No tenant |
|---|---|---|---|---|
db.execute set_config(…, false), then the query | 20 | 20 | 10 | 10 |
db.execute set_config(…, true), then the query | 0 | 0 | 0 | 0 |
db.transaction, set_config(…, true) first | 20 | 0 | 10 | 0 |
Rows counted per request, one request at a time. Bold is another customer’s data; italics is a customer who sees none of their own.
db.execute and the select after it are two separate calls on the pool. One at a time they happened to get the same connection, and the session-level setting stayed on it for the next request. The transaction-local form does not leak, but outside a transaction the statement is its own transaction and the setting is gone before the query runs.
Under concurrency the two calls come apart. Thirty requests at a time, twenty rounds; one run, and a repeat gave the same split within a few requests:
| Request for | Own rows | Another customer’s rows | No rows |
|---|---|---|---|
| tenant 1 (200) | 104 | 94 | 2 |
| tenant 2 (200) | 97 | 102 | 1 |
| no tenant (200) | — | 196 | 4 |
With db.transaction the same load gave 200 of 200 right for both tenants and 0 rows for all 200 requests without one. This is the same race we measured in Prisma and PostgreSQL row-level security: nothing ties a setting to the query that follows unless a transaction holds them on one connection.
export function withTenant<T>(tenant: string, fn: (tx: Tx) => Promise<T>) {
return db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.tenant', ${tenant}, true)`);
return fn(tx);
});
}
Over 300 requests one at a time, in our runs the session-level variant took about 0.35 ms per request and the transaction about 0.5 ms: roughly 0.1–0.15 ms for the begin and commit.
What does drizzle-kit leave out?
The schema above, with pgPolicy and .enableRLS(), generated this migration:
ALTER TABLE "invoices" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "invoices" AS PERMISSIVE FOR ALL TO public USING (…);
There is no FORCE ROW LEVEL SECURITY, and we found no option for it in drizzle-orm 0.45.3, in drizzle-kit 0.31.11 or on Drizzle’s RLS page. Without FORCE, PostgreSQL does not apply the policy to the table’s owner. We ran the generated migration as a login role owner and queried as that role without any tenant: 30 of 30 rows. If your application connects with the same credentials that run the migrations — as it does when the application and the migrations share one DATABASE_URL — the policy does nothing for it. Add ALTER TABLE … FORCE ROW LEVEL SECURITY in a custom migration, or, better, let the application connect as a role that does not own the tables. Gap 1 in row-level security in PostgreSQL: where tenants leak has the full measurement.
What goes wrong in the documented wrapper?
Drizzle’s RLS page shows a wrapper for Supabase that opens a transaction, sets the token claims with set_config(…, TRUE), runs your callback, and in a finally block sets the claims back to NULL and resets the role. The structure is right — transaction-local, in one transaction — and with our tenant setting in its place it counted 20 for tenant 1. Two details are not.
The finally hides the real error. When a query in the callback fails, PostgreSQL aborts the transaction, and the reset in finally is then a statement in an aborted transaction. It fails, and in JavaScript an exception thrown from finally replaces the one in flight. An insert that broke the check constraint surfaced as Failed query: select set_config('app.tenant', NULL, TRUE), cause “current transaction is aborted, commands ignored until end of transaction block”. The same insert in a plain db.transaction reported “new row for relation “invoices” violates check constraint “invoices_amount_check"". The reset is not needed when the wrapper opens the transaction itself: a setting made with TRUE ends with the transaction anyway. Leave the finally out. (Nested inside an existing transaction, as a savepoint, the setting would last until the outer transaction ends — another reason to open the transaction in one place.)
The claims are interpolated, not bound. The example writes '${sql.raw(JSON.stringify(token))}' — the JSON pasted into the SQL text between single quotes. A claim with a quote in it, such as the name Anne O'Brien, made the statement fail with syntax error at or near "Brien". A claim value can change the statement that runs; the token is signed, but a claim can carry data a user typed. Passing the value as a parameter — ${JSON.stringify(token)} without sql.raw and without the quotes, which Drizzle sends as $1 — stored the same claims intact. Do that one statement per tx.execute: the documented block runs three statements in one call, and node-postgres refuses that once it carries a parameter (“cannot insert multiple commands into a prepared statement”). The example does the same with token.sub and with the role in set local role; a role name cannot be a parameter, so check it against a fixed list before you use it.
What we did not measure
Supabase and Neon themselves, Drizzle on other drivers (postgres.js, Neon serverless, PGlite), drizzle-kit push, and Drizzle behind PgBouncer in transaction mode — the rules for a pooler are measured in PgBouncer and row-level security. The same question for other frameworks is measured for Django, SQLAlchemy, Prisma and Laravel. The timings come from one machine and are for comparison.
Sources: Drizzle ORM’s documentation on row-level security (pgPolicy, enabling RLS, the Supabase wrapper), read on 4 October 2026, and the drizzle-orm 0.45.3 type definitions. All measurements are our own, on drizzle-orm 0.45.3, drizzle-kit 0.31.11 and PostgreSQL 17.10, on 4 October 2026.