SaaS architecture
TypeORM and PostgreSQL row-level security, measured
Setting the tenant for PostgreSQL row-level security in TypeORM 1.1, measured: dataSource.query, QueryRunner, transactions, and an injected repository.
Row-level security in PostgreSQL needs to know the tenant, and the usual way to tell it is a setting on the connection: set_config('app.tenant', …). TypeORM gives you three handles on a connection — dataSource.query(), a QueryRunner, and dataSource.transaction() — and in a NestJS application a fourth thing that looks like one: the repository injected into a service. Underneath is the pg driver’s pool, which keeps the database session alive between requests and runs each call on whichever connection is free.
We measured six ways to set the tenant. The obvious one, dataSource.query() before the repository call, gave about half of the requests another customer’s count once 30 ran at a time. The transaction that held everywhere stopped holding the moment the query inside it used the injected repository instead of the transaction’s own manager: every count was 0, and with 30 requests in flight the pool stopped answering altogether.
The setup
TypeORM 1.1.2 with pg 8.23.1 (pg-pool 3.14.0) on Node.js 22.23; PostgreSQL 17.11 in a throwaway container. TypeORM’s default pool of 10 connections, which pg-pool fills in when no size is given; we repeated the sequential, concurrent and timing runs with extra: { max: 10 } set explicitly and got the same picture. Measured on 15 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)with the samewith check. TypeORM connects as an ordinary role, not the table owner. - Each simulated request sets the tenant the way the variant does and counts the invoices with
repository.count(). A query subscriber records which backend process ran each statement, so the SQL of the variants is unchanged. - A fresh pool for every variant and run, so one variant’s leftover setting cannot reach the next.
The six variants:
dataSource.query("select set_config('app.tenant', $1, false)", [tenant]), then the repository.- The same with
set_config(…, true). - A
QueryRunnerper request:connect(),set_config(…, false), count throughqueryRunner.manager,release(). - The same, with
set_config('app.tenant', '', false)beforerelease(). dataSource.transaction(async (manager) => …):set_config(…, true)through the manager, count throughmanager.getRepository(Invoice).- The same transaction, but the count through a repository created once from the
DataSource— the kind a NestJS service gets from@InjectRepository(in @nestjs/typeorm 12.0.2 a singleton made bydataSource.getRepository()).
What did the request without a tenant see?
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 endpoint, a health check, a route someone forgot to guard.
| Variant | Tenant 1 | No tenant | Tenant 2 | No tenant |
|---|---|---|---|---|
1 · dataSource.query, session-level | 20 | 20 | 10 | 10 |
2 · dataSource.query, set_config(…, true) | 0 | 0 | 0 | 0 |
3 · QueryRunner, no reset | 20 | 20 | 10 | 10 |
4 · QueryRunner, reset before release | 20 | 0 | 10 | 0 |
5 · dataSource.transaction, manager’s repository | 20 | 0 | 10 | 0 |
6 · dataSource.transaction, injected repository | 0 | 0 | 0 | 0 |
Rows counted per request, two runs each with identical results. Bold is another customer’s data; italics is a customer who sees none of their own.
Variant 2 fails the way it does in Drizzle and Prisma: outside a transaction, set_config(…, true) is its own transaction and the setting is gone before the count runs. Variants 1 and 3 leak a session-level tenant into the next request, because the pool does not reset it when a connection goes back.
What happened under concurrent requests?
600 requests, 30 in flight at all times; first only tenants 1 and 2, then a third of them without a tenant. Two runs of each:
| Variant | Tenant requests with a wrong count | Requests without a tenant that saw rows |
|---|---|---|
1 · dataSource.query, session-level | 302 and 318 of 600 · 224 and 198 of 400 | 197 and 195 of 200 |
2 · dataSource.query, set_config(…, true) | all of them, counted 0 | 0 |
3 · QueryRunner, no reset | 0 | 195 and 193 of 200 |
4 · QueryRunner, reset before release | 0 | 0 |
5 · dataSource.transaction, manager’s repository | 0 | 0 |
6 · dataSource.transaction, injected repository | did not finish | did not finish |
Variant 1 only looks right one request at a time. dataSource.query() and the repository each take a connection from the pool and give it back; with one request at a time the pool had opened a single connection, and the two statements ran on the same backend 20 times out of 20. With 30 in flight they shared a backend in between 1 and 9 of the tenant requests in a run (of 600 or 400), and the count came from whichever tenant had last been set on the connection the repository got.
Variant 3 keeps the setting and the count on one connection, so a request that sets its tenant gets its own rows. It fails only for a request that sets nothing — at 30 in flight, almost every one of those.
Why did the injected repository freeze the pool?
dataSource.transaction() takes a connection and holds it until the callback returns. A repository from dataSource.getRepository() is not bound to that transaction: its count() asks the pool for a second connection. One request at a time, that second connection was a different backend in every run (variant 6 in the first table), which is why the count was 0 — the tenant was set in a transaction the count was not part of.
- Ten requests
dataSource.transaction()takes a connection for each and sets its tenant.10 of 10 connections in an open transaction - Twenty requests
dataSource.transaction()asks the pool for a connection.20 waiting - The first tenInside the callback, the injected repository's
count()asks the pool for a second connection.30 waiting, 0 idle - PoolA connection comes back only when a transaction ends, and every open transaction is waiting for a connection.10 sessions
idle in transaction; no request finishes
Measured: the pool's counts (10 in use, 0 idle, 30 waiting) and the 10 sessions idle in transaction when we stopped the run. The order of steps 1 to 3 is derived; we did not record which request took which connection when.
With 30 requests in flight, all 10 connections were held by open transactions, each waiting inside its callback for an eleventh connection that only a finished transaction could free. When we stopped the run after 10 or 20 seconds, the pool reported 10 connections in use, none idle and 30 requests waiting, and PostgreSQL showed 10 sessions idle in transaction. Nothing inside the application ends that: pg-pool’s default is to wait indefinitely for a connection. With connectTimeoutMS: 2000 the run finished in 44 seconds, with 516 and 486 of the 600 requests failing on timeout exceeded when trying to connect and every request that got through counting 0; with 5000 it had not finished after 20 seconds. On the database side, idle_in_transaction_session_timeout set to 2 seconds on the role ended the stuck sessions instead: 111 of 120 requests failed with Query runner already released, the other 9 counted 0. A timeout turns the stall into failed requests; it does not fix it. At exactly 10 requests in flight it froze as well; at 5 and at 9 it finished, with every count 0.
Writes through the injected repository went to that second connection too. An insert for tenant 2 inside the transaction failed with new row violates row-level security policy for table "invoices": on the second connection no tenant was set, so the policy’s with check refused the row. That fails safely only as long as the second connection is clean. We left set_config('app.tenant', '2', false) on both connections of a pool of two, as variant 3 would, and ran a save() for tenant 2 through the injected repository, followed by an exception: save() opened its own transaction on the other connection, the insert succeeded and committed, and the outer transaction’s rollback did not touch it. The leftover tenant was the one we wrote for, so this shows a write escaping the transaction, not a write into another customer’s data; a read through the same repository uses whichever tenant was left on the connection.
What works?
A transaction per request, with the tenant as its first statement, and every query of that request going through the transaction’s manager:
const count = await dataSource.transaction(async (manager) => {
// '' when the request has no tenant: skipping the call is not the same
await manager.query("select set_config('app.tenant', $1, true)", [tenantId == null ? '' : String(tenantId)]);
return manager.getRepository(Invoice).count();
});
It held in all four sequential requests and in every concurrent run, and an insert followed by an exception in the callback was rolled back. set_config(…, true) ends with the transaction, so the connection goes back to the pool without a tenant. Set it to '' for a request without a tenant rather than skipping the call. Our measured variant skipped it, and on a connection where another code path had left a session-level tenant 2, a request without a tenant counted tenant 2’s 10 invoices; with '' set explicitly, the same request counted 0. Nor does the pattern cover a query that goes around the manager, a role that owns the table or has BYPASSRLS, or a tenant ID taken from an untrusted source.
The part that makes it hard in a NestJS service is the last condition: an injected repository, or any other service that queries through the DataSource, does not take part. In a service, get the repository from the manager (manager.getRepository(Invoice)) inside the callback, or pass the manager down. A QueryRunner with a reset before release() (variant 4) was also correct everywhere, but only for queries that go through queryRunner.manager, and the reset has to run on every path, including errors — put it in a finally.
TypeORM 1.1.2 has no hook that runs, and is awaited, each time a connection is taken from the pool. afterConnect() runs once when the DataSource initialises; subscribers see queries and transactions (afterTransactionStart is awaited, but fires per transaction), not checkouts. Underneath, pg-pool’s onConnect runs once per new physical connection, and its acquire and release events are not awaited. Setting the tenant “on every checkout” is therefore not something TypeORM offers; we read this in the source of both packages and did not build a workaround.
What does it cost?
Average over 3,000 sequential requests alternating between tenant 1 and tenant 2, after 100 warm-up requests, two runs each, on one machine with the database next door:
| Variant | Per request |
|---|---|
1 · dataSource.query, session-level (leaks) | 0.35–0.42 ms |
4 · QueryRunner, reset before release | 0.54–0.61 ms |
5 · dataSource.transaction, manager’s repository | 0.42–0.47 ms |
The transaction — four statements — was faster than the QueryRunner with its reset in both runs, and close to the leaking variant: 0.42 and 0.47 ms against 0.42 and 0.35 ms.
What we did not measure
NestJS itself (the requests were simulated in Node.js, with the repository created once, as dependency injection does), packages that wrap this pattern, request-scoped providers, PgBouncer in front of the database (measured in PgBouncer and row-level security), and background jobs, which have their own failure modes, measured in PostgreSQL job workers: retries, crashes, tenant context. The same question for other stacks is measured for Prisma, Drizzle, Spring Boot and Hibernate, Django, SQLAlchemy, Laravel and Go with pgx. The timings come from one machine and are for comparison.
Sources: the TypeORM 1.1.2 source (PostgresDriver.afterConnect, PostgresQueryRunner.connect, the subscriber interface) and pg-pool 3.14.0 (onConnect, the acquire and release events, the default pool size and connection timeout), read on 15 October 2026. All measurements are our own, on TypeORM 1.1.2 and PostgreSQL 17.11, on 15 October 2026.