SaaS architecture
Hibernate multi-tenancy and PostgreSQL RLS in Spring Boot
Where to set the tenant for PostgreSQL row-level security in Spring Boot 4 and Hibernate 7, measured: DataSource wrapper, interceptor, @Transactional, provider.
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', …). A Spring Boot application offers four obvious places to run that line: a wrapper around the DataSource that sets it whenever a connection leaves the pool, a HandlerInterceptor that sets it before the controller runs, the first statement of a @Transactional service method, and Hibernate’s own MultiTenantConnectionProvider. Behind all four sits HikariCP, which keeps the database session alive between requests.
We measured all four. Two of them handed a request without a tenant the previous customer’s invoices. The interceptor, under 30 concurrent requests, also gave requests that had set their tenant another customer’s rows. The @Transactional method and Hibernate’s provider kept every tenant apart — and the provider did so only for queries on a connection that Hibernate handed out.
The setup
Spring Boot 4.1.1 with Spring Framework 7.0.9, Spring Data JPA 4.1.1, Hibernate ORM 7.4.5, HikariCP 7.0.2 (default pool of 10 connections), the PostgreSQL JDBC driver 42.7.13 and Tomcat 11.0.24, on Java 21; PostgreSQL 17.11 in a throwaway container. Measured on 15 October 2026. Everything else is Spring Boot’s default, including spring.jpa.open-in-view, which is on: Boot registers OpenEntityManagerInViewInterceptor and logs a warning at startup that it did.
- 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. The application connects as an ordinary role, not the table owner. - A servlet filter reads
?tenant=into aThreadLocaland clears it when the request ends. One endpoint counts invoices through a Spring Data repository (repository.count()) and returns the count andpg_backend_pid(). - Four HTTP 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 controller someone forgot to cover.
What did the request without a tenant see?
| Where the tenant is set | Tenant 1 | No tenant | Tenant 2 | No tenant |
|---|---|---|---|---|
DataSource wrapper, sets it when there is a tenant | 20 | 20 | 10 | 10 |
DataSource wrapper, sets '' when there is none | 20 | 0 | 10 | 0 |
HandlerInterceptor, set_config(…, false) | 20 | 20 | 10 | 10 |
HandlerInterceptor, set_config(…, true) | 0 | 0 | 0 | 0 |
@Transactional method, set_config(…, true) first | 20 | 0 | 10 | 0 |
Hibernate MultiTenantConnectionProvider | 20 | 0 | 10 | 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. In every run all four requests ran on the same PostgreSQL backend.
The first row is the version that turns up when you search for this: a subclass of the pool’s DataSource whose getConnection() runs a statement with the tenant from a ThreadLocal. HikariCP does not reset a custom setting when a connection goes back, so a request that sets nothing reads what the previous one left. Setting an empty string when there is no tenant closed that gap in our runs.
set_config(…, true) in an interceptor returned nothing at all, for the reason it does in Laravel and Django: outside a transaction the JdbcTemplate call is its own transaction, and a transaction-local setting is gone before the repository’s query runs.
What happened under concurrent requests?
600 requests, 30 at a time, through Tomcat’s thread pool; first only tenants 1 and 2, then a third of the requests without a tenant. Two runs of each:
| Where the tenant is set | Tenant requests with another tenant’s count | Requests without a tenant that saw rows |
|---|---|---|
DataSource wrapper, only with a tenant | 0 of 600 · 0 of 400 | 200 of 200 |
DataSource wrapper, '' without a tenant | 0 | 0 |
HandlerInterceptor, set_config(…, false) | 192 and 39 of 600 · 77 and 24 of 400 | 200 of 200 |
@Transactional method | 0 | 0 |
Hibernate MultiTenantConnectionProvider | 0 | 0 |
The interceptor is the one that also hurts requests that set their tenant. Outside a transaction, JdbcTemplate takes a connection from the pool, runs set_config and gives it back. The repository’s query then takes a connection again. One request at a time, that was the same connection every time — which is why the sequential test above looks only half wrong. With 30 requests in flight it was not always the same connection, and the count came from whichever tenant had last been set on the connection it got. The number varied a lot between runs; in none of our runs was it zero. Turning off spring.jpa.open-in-view did not change that: 151 of 600 and 33 of 400 wrong in a rerun.
- Request A, tenant 1The interceptor's
JdbcTemplatetakes connection X, sets tenant 1 and gives X back.X: tenant 1 - Request B, tenant 2Its interceptor takes connection Y, sets tenant 2 and gives Y back.Y: tenant 2
- Request AThe repository takes a connection for its query and gets Y.A counts 10: tenant 2's invoices
Measured: 192 and 39 of 600 requests counted another tenant's invoices, and one request at a time every statement ran on the same backend. In a rerun with the backends logged, 156 of 167 wrong counts came from a repository query on a different backend than the interceptor's, as here; the other 11 ran on the same backend, after another request had set its tenant there in between.
The wrapper does not have this problem, because it sets the tenant on the connection that is about to be used. But it only runs when something asks the DataSource for a connection, and it reads a ThreadLocal: anything that runs on another thread — an @Async method, a scheduled job — would find an empty ThreadLocal and run without a tenant. We did not measure those.
What does Hibernate’s connection provider cover?
Hibernate asks the provider for a connection and passes the tenant identifier that a CurrentTenantIdentifierResolver returned for the session. Our provider runs set_config('app.tenant', ?, false) in getConnection(tenant) — with an empty string when the resolver returned no tenant — and resets it in releaseConnection. Registered through a HibernatePropertiesCustomizer (hibernate.multi_tenant_connection_provider and hibernate.tenant_identifier_resolver), it kept tenants apart in every run above.
It covers what runs on a connection Hibernate handed out: Hibernate’s own statements, and a JdbcTemplate call inside a @Transactional method, which shares that connection. A JdbcTemplate call outside a transaction takes a connection from the pool on its own and is not covered. In the same request, after the repository counted 20 for tenant 1, such a query counted 0. With Open EntityManager in View on, that was a second connection; with it off, the one Hibernate had just released — either way the reset had cleared the tenant. Without the reset in releaseConnection, that second path went wrong in the other direction: after a request for tenant 1, a JdbcTemplate query in a request without a tenant counted tenant 1’s 20 invoices; after a request for tenant 2, a JdbcTemplate query in a request for tenant 1 counted tenant 2’s 10.
Mixing the two outside a transaction has a second cost. With Open EntityManager in View on, a request that calls the repository and then JdbcTemplate holds two connections; at 30 concurrent requests against a pool of 10, our requests timed out after 30 seconds waiting for a connection (total=10, active=10, idle=0, waiting=29).
Inside a @Transactional method the two paths are one: JpaTransactionManager binds the connection to the transaction, and a JdbcTemplate query in the same method ran on the same backend and counted the same rows as the repository (20, 0, 10, 0).
Does Hibernate’s discriminator multi-tenancy replace RLS?
Not for SQL that Hibernate does not write. Hibernate’s discriminator (partitioned) multi-tenancy is the @TenantId annotation on the tenant column. We mapped a second table, without a policy, with Hibernate’s @TenantId on the tenant column and the same resolver. Spring Data’s count() and a JPQL count returned 20 for tenant 1, 10 for tenant 2 and 0 without a tenant. A native query on the same repository — select count(*) from notes — returned 30 for all three. @TenantId is a filter that Hibernate adds to the statements it generates; a native query, a JdbcTemplate call or a report that connects directly sees the whole table. Row-level security is enforced by the database, whichever of those runs.
What works?
A transaction that the request’s queries run in, with the tenant as its first statement:
@Service
public class InvoiceService {
@PersistenceContext EntityManager em;
private final InvoiceRepository invoices;
InvoiceService(InvoiceRepository invoices) { this.invoices = invoices; }
@Transactional
public long countForCurrentTenant() {
Integer tenant = TenantContext.get(); // filled by a servlet filter
if (tenant != null) {
em.createNativeQuery("select set_config('app.tenant', :t, true)")
.setParameter("t", tenant.toString())
.getSingleResult();
}
return invoices.count();
}
}
set_config(…, true) lasts until the transaction ends, so the setting is gone when the transaction commits, before the connection is used again; JdbcTemplate in the same method sees the same tenant. In our runs this held in all four sequential requests and in all 1,200 concurrent ones, and in a rerun with the repository and JdbcTemplate in the same method, 1,200 more.
What it does not catch is a query outside such a method: in the same request it ran without a tenant and, with this policy, saw no rows, and an insert without a tenant was refused with new row violates row-level security policy. A bug you notice, not a leak. The if (tenant != null) relies on nothing else in the application ever setting the tenant at session level — a leftover DataSource wrapper, a migration script on the same pool. If that cannot be ruled out, set '' with true when there is no tenant, or refuse the request. We measured the statement written out in the method. Moving it into an aspect is the obvious next step, which we did not measure; we would expect such an aspect to work only if it runs inside the transaction, after the transaction interceptor has opened it — before that, the setting is gone before the query runs, as in the set_config(…, true) interceptor row.
Hibernate’s provider is the alternative when every query goes through Hibernate. Set the tenant on every getConnection, also when there is none, and reset it on release; anything that bypasses Hibernate then sees no rows rather than someone else’s.
One Spring default matters for a write inside that transaction. We inserted an invoice for tenant 2 and threw: with a RuntimeException the insert was rolled back (10 before, a 500, 10 after), with a checked exception it was committed (11 after, also with a 500). That is documented behaviour — “Checked exceptions that are thrown from a transactional method do not result in a rollback in the default configuration” — and @Transactional(rollbackFor = Exception.class) changes it.
What does it cost?
Average over 3,000 sequential HTTP requests alternating between tenant 1 and tenant 2, after 1,000 warm-up requests, two runs each, on one machine with the database next door:
| Where the tenant is set | Per request |
|---|---|
DataSource wrapper, '' without a tenant | 0.61–0.77 ms |
HandlerInterceptor, set_config(…, false) (wrong under load) | 0.64–0.74 ms |
@Transactional method | 0.55–0.92 ms |
Hibernate MultiTenantConnectionProvider | 0.63–0.77 ms |
The spread between runs of the same variant, up to about 0.35 ms, was larger than any difference between them; the 0.92 ms is from a third run during our review. The transaction costs nothing we could measure.
What we did not measure
PgBouncer in front of the database (the rules for a pooler are measured in PgBouncer and row-level security), Spring WebFlux with R2DBC, virtual threads, @Async and scheduled jobs, and an aspect that sets the tenant. With spring.jpa.open-in-view=false we reran only the interceptor and the provider, as noted above. A worker that serves every tenant has its own failure modes, measured in PostgreSQL job workers: retries, crashes, tenant context. The same question for other stacks is measured for Django, SQLAlchemy, Prisma, Drizzle, Laravel and Go with pgx; the other ways a policy leaks are in row-level security in PostgreSQL: where tenants leak. The timings come from one machine and are for comparison.
Sources: the Spring Framework reference on rolling back a declarative transaction, the Spring Boot reference on Open EntityManager in View, and the Hibernate ORM user guide on multitenancy, read on 15 October 2026. All measurements are our own, on Spring Boot 4.1.1, Hibernate ORM 7.4.5 and PostgreSQL 17.11, on 15 October 2026.