Configuring Hibernate Multi-Tenancy
Hibernate routes every JDBC connection through two pluggable interfaces, and wiring them correctly is the difference between watertight tenant isolation and silent cross-tenant data leaks. This guide sits within ORM middleware for multi-tenancy and shows how to bootstrap a SessionFactory that resolves the active tenant per request and binds it to the right data source.
Problem Framing
Hibernate has no built-in notion of who is making a query. By default a SessionFactory opens connections from a single pool against a single database. In a SaaS application that serves many tenants from the same JVM, you need each Session to read and write only the data that belongs to the caller. Hibernate solves this with two contracts you implement yourself: CurrentTenantIdentifierResolver, which answers "which tenant is this?", and MultiTenantConnectionProvider, which answers "where do that tenant's bytes physically live?".
The failure that matters most is order of operations. Hibernate calls the resolver first, then hands the returned identifier to the connection provider to select a DataSource or SET search_path. If the resolver reads from a ThreadLocal that a previous request left populated, or the connection provider falls back to a default pool on an unknown ID, the session quietly serves another tenant's rows. There is no exception, no log line, just wrong data in a response. This is why both components must validate aggressively and why the thread-bound context must be cleared after every request.
The second trap is identity provenance. The tenant identifier must originate from something the caller cannot forge. A header like X-Tenant-ID is convenient for the connection provider but is attacker-controlled, so it can only be trusted after it has been reconciled against an authenticated claim. Treat the resolver as a security boundary, not a convenience: it is the last place in the stack where a bad identifier can be rejected before it reaches a physical data source. Once Hibernate has opened a connection against the wrong pool, every query in that session is already compromised.
Before writing code, choose a strategy. DATABASE gives a physical database per tenant and the strongest isolation; SCHEMA shares one instance and switches search_path; DISCRIMINATOR keeps everything in one schema and filters on a tenant_id column. The strategy is fixed per SessionFactory and cannot be mixed.
| Strategy | Isolation | Connection routing | Best fit |
|---|---|---|---|
DATABASE |
Physical, per tenant | Distinct DataSource per tenant |
HIPAA/regulated, ~100–500 tenants |
SCHEMA |
Logical, shared instance | One pool, SET search_path |
Mid-density, ~500–2000 tenants |
DISCRIMINATOR |
Row-level filter | Single pool | High density, application-trusted |
Step-by-Step Guide
1. Bind tenant context per request
Resolve the tenant once at the edge and store it in a ThreadLocal. The critical detail is the finally block: without it, pooled threads carry stale context into the next request.
public class TenantContextFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
String tenantId = ((HttpServletRequest) req).getHeader("X-Tenant-ID");
try {
TenantContextHolder.setTenantId(tenantId);
chain.doFilter(req, res);
} finally {
TenantContextHolder.clear(); // prevents ThreadLocal bleed across requests
}
}
}
2. Implement CurrentTenantIdentifierResolver
The resolver reads the bound context and rejects anything malformed before it can reach the connection provider. Validate the format here so a crafted header cannot select an unintended pool.
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import java.util.regex.Pattern;
public class TenantResolver implements CurrentTenantIdentifierResolver<String> {
private static final Pattern UUID_RE = Pattern.compile(
"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$");
@Override
public String resolveCurrentTenantIdentifier() {
String tenantId = TenantContextHolder.getCurrentTenantId();
if (tenantId == null || tenantId.isBlank()) {
throw new IllegalStateException("Missing tenant context in request lifecycle");
}
if (!UUID_RE.matcher(tenantId).matches()) {
throw new SecurityException("Invalid tenant identifier format");
}
return tenantId;
}
@Override
public boolean validateExistingCurrentSessions() {
return false; // strict per-request session isolation
}
}
3. Implement MultiTenantConnectionProvider
The provider maps a tenant identifier to a real connection source. Extending AbstractMultiTenantConnectionProvider lets you supply per-tenant ConnectionProvider instances and reuse Hibernate's connection lifecycle handling.
import org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider;
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
import com.zaxxer.hikari.HikariDataSource;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class TenantConnectionProvider
extends AbstractMultiTenantConnectionProvider<String> {
private final Map<String, ConnectionProvider> pools = new ConcurrentHashMap<>();
@Override
protected ConnectionProvider getAnyConnectionProvider() {
return pools.values().iterator().next();
}
@Override
protected ConnectionProvider selectConnectionProvider(String tenantId) {
return pools.computeIfAbsent(tenantId, this::createPool);
}
private ConnectionProvider createPool(String tenantId) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:postgresql://db-cluster.internal:5432/tenant_" + tenantId);
ds.setMaximumPoolSize(10);
ds.setConnectionTimeout(2000);
ds.setPoolName("tenant-pool-" + tenantId);
return new HikariConnectionProvider(ds);
}
}
For the SCHEMA strategy you keep a single pool and override getConnection(String, String) to issue SET search_path TO tenant_<id> after acquiring the connection, then reset it to the default on release so a borrowed connection never carries one tenant's search_path into the next checkout. For DATABASE, the per-tenant pool above is exactly right, but the lazy computeIfAbsent means the first request for a new tenant pays the pool warm-up cost; pre-create pools for known high-traffic tenants at startup if that latency matters. Either way, the connection your provider returns and the connection Hibernate releases must correspond to the same tenant context, or you corrupt the pool.
4. Register both beans in the SessionFactory
Hibernate only invokes your interfaces if they are registered under the correct property keys. Set the strategy, name the dialect explicitly, and disable runtime DDL so schema changes only ever come from versioned migrations.
spring:
jpa:
properties:
hibernate:
multiTenancy: SCHEMA
multi_tenant_connection_provider: com.app.persistence.TenantConnectionProvider
tenant_identifier_resolver: com.app.persistence.TenantResolver
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
time_zone: UTC
hibernate:
ddl-auto: none
5. Enforce row filters for DISCRIMINATOR
If you chose DISCRIMINATOR, Hibernate does not auto-append a tenant_id predicate. Add @TenantId (Hibernate 6+) so the column is set on insert and filtered on read without manual WHERE clauses.
import org.hibernate.annotations.TenantId;
import jakarta.persistence.*;
@Entity
public class Invoice {
@Id @GeneratedValue Long id;
@TenantId
@Column(name = "tenant_id", updatable = false)
String tenantId;
BigDecimal amount;
}
This is the same scoping concern that Prisma client extensions for tenant scoping solves in the TypeScript ecosystem; the principle is identical, only the ORM hook differs.
Verification
Prove isolation with an integration test that runs two requests under different tenant contexts and asserts neither sees the other's rows. Use Testcontainers so the assertion runs against a real PostgreSQL instance.
@Test
void sessionsAreScopedToTheBoundTenant() {
TenantContextHolder.setTenantId("a1b2c3d4-0000-4000-8000-000000000001");
Long idA = repo.save(new Invoice(BigDecimal.TEN)).getId();
TenantContextHolder.clear();
TenantContextHolder.setTenantId("a1b2c3d4-0000-4000-8000-000000000002");
assertThat(repo.findById(idA)).isEmpty(); // tenant B cannot see tenant A's row
TenantContextHolder.clear();
}
Confirm the provider actually switches sources by enabling Hibernate's connection logging and watching the pool name change per request:
logging.level.org.hibernate.engine.jdbc.connections=DEBUG
A correct run logs tenant-pool-...0001 for the first request and tenant-pool-...0002 for the second. If both log the same pool name, your resolver is reading stale context. Run the same assertion concurrently from two threads to catch the bleed that a single-threaded test will miss: spawn both requests on a shared executor, and verify each thread still sees only its own tenant once the pool starts reusing carrier threads.
Failure Modes & Gotchas
- Symptom: A request returns another tenant's data with no error. Root cause: the
ThreadLocalwas not cleared, so a pooled thread reused stale context. Fix: clear context in afinallyblock in the filter (Step 1). - Symptom:
HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specified. Root cause: the resolver returnednullbecause no context was bound for this code path (often a background job). Fix: set tenant context explicitly before opening sessions outside the request thread. - Symptom: Pool count grows unbounded and connections exhaust. Root cause:
computeIfAbsentcreates a permanent pool per tenant and never evicts inactive ones. Fix: cap concurrent pools and close idleHikariDataSourceinstances on an eviction timer. - Symptom:
MappingExceptionon routing to a specific tenant. Root cause: that tenant's schema is behind on migrations. Fix: run Flyway/Liquibase per tenant and block routing to any schema below the expected version.
FAQ
Can I mix DATABASE and SCHEMA strategies in one SessionFactory?
No. The hibernate.multiTenancy setting is fixed per SessionFactory. For a hybrid architecture, build separate SessionFactory instances and route to the right one at the application layer.
How do I prevent tenant ID spoofing from the X-Tenant-ID header? Never trust the header as authority. Resolve the tenant from authenticated JWT claims, then assert the header (if used) matches the claim, and enforce the UUID format in the resolver before Hibernate ever sees it.
Does the resolver run for background jobs and scheduled tasks?
Only if you bind the context yourself. Off-request threads have no filter, so wrap the job body in explicit setTenantId() / clear() calls, exactly as the servlet filter does for requests.