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

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.