Skip to content
Back to blog
6 min read

Multi-Tenant Data Isolation in Spring Boot with Hibernate Filters

Spring BootHibernateMulti-tenancyPostgreSQL

I'm building Raaqib, a stock management SaaS for small clothing retailers in Morocco. Every store is a tenant, and they all share one Spring Boot application and one PostgreSQL database. Which means the single most expensive bug I could ship is one store seeing another store's stock.

This is how I isolate tenant data, and — more usefully — the four ways the approach leaks if you set it up naively.

Picking an isolation strategy

There are three realistic options, and they trade operational cost against blast radius.

Database or schema per tenant. The strongest isolation: a MultiTenantConnectionProvider swaps the connection or issues SET search_path per request. A leak is close to impossible, because the wrong data isn't reachable in the first place. But every schema change has to run against every tenant, connection pooling gets awkward (you're either pooling per tenant or resetting search_path on every checkout), and onboarding a store becomes a migration job rather than an insert. For a product where a tenant is a small clothing shop and I want signup to be instant, that's a lot of machinery.

Discriminator column, enforced manually. Add tenant_id to every table and remember to filter on it in every query. This is the one that fails. Not on day one — on the day someone adds findByBarcode and forgets.

Discriminator column, enforced by the ORM. Same schema, but Hibernate appends the tenant predicate itself, so a developer writing a repository method physically cannot forget. That's what I went with, using Hibernate's @Filter.

The honest trade-off: isolation now depends on my application being correct, not on the database refusing. I accept that, and I spend the savings on tests that try to break it.

The setup

Every tenant-scoped entity carries a tenant_id and declares the filter. I put both on a mapped superclass so it's one decision instead of one per entity:

@MappedSuperclass
@FilterDef(
    name = "tenantFilter",
    parameters = @ParamDef(name = "tenantId", type = UUID.class),
    applyToLoadByKey = true // also filter loads by primary key
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class TenantEntity {

    @Column(name = "tenant_id", nullable = false, updatable = false)
    private UUID tenantId;

    // getters / setters
}

The tenant itself is resolved once per request. A servlet filter placed after authentication reads the tenant claim off the validated JWT and binds it to the thread:

public final class TenantContext {

    private static final ThreadLocal<UUID> CURRENT = new ThreadLocal<>();

    public static UUID require() {
        UUID tenantId = CURRENT.get();
        if (tenantId == null) {
            throw new IllegalStateException("No tenant bound to this thread");
        }
        return tenantId;
    }

    public static void set(UUID tenantId) { CURRENT.set(tenantId); }

    public static void clear() { CURRENT.remove(); }
}

Two details in there matter more than they look. require() throws instead of returning null — an unbound tenant is a bug, and I would much rather fail the request than run an unfiltered query. And clear() calls remove() rather than set(null): on a pooled request thread a stale value is a cross-tenant read waiting to happen, so the servlet filter clears it in a finally block, always.

Then the filter has to be turned on. Hibernate filters live on the session and are off by default, so enabling one at startup does nothing. It has to be enabled inside every transaction:

@Aspect
@Component
public class TenantFilterAspect {

    @PersistenceContext
    private EntityManager entityManager;

    @Before("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void enableTenantFilter() {
        entityManager.unwrap(Session.class)
            .enableFilter("tenantFilter")
            .setParameter("tenantId", TenantContext.require());
    }
}

Reads are now scoped. Writes are not — a filter is a WHERE clause and has no opinion about INSERT. So tenant_id gets stamped on the way in, by a lifecycle callback rather than by hand at every call site:

@PrePersist
void stampTenant() {
    if (tenantId == null) {
        tenantId = TenantContext.require();
    }
}

The four ways it leaks anyway

This is the part worth knowing, because getting the annotations right is the easy half.

1. find() by primary key bypasses the filter. By default, filters apply to queries but not to load-by-id. So productRepository.findById(id) will happily hand you another tenant's product — and since IDs usually come straight from the URL, that's a live IDOR. Hibernate 6.2 added applyToLoadByKey = true, which is why it's on the @FilterDef above. On older versions, or if you want belt and braces, the fallback is an explicitly scoped lookup:

@Query("select p from Product p where p.id = :id and p.tenantId = :tenantId")
Optional<Product> findScopedById(@Param("id") UUID id, @Param("tenantId") UUID tenantId);

2. Lazy collections aren't covered by the parent's filter. Loading a Supplier and walking supplier.getProducts() issues a fresh collection query, and the filter has to be declared on the association as well. I mostly sidestep this: anything crossing an aggregate boundary is loaded through a repository query, not by navigating the object graph.

3. @Async and scheduled jobs have no tenant. A ThreadLocal doesn't cross a thread pool. A nightly stock report or an export runs with an empty context — which is precisely why require() throws. The job fails loudly instead of quietly running a query with no tenant predicate. Background work takes the tenant as an explicit argument and binds it at the top of the task.

4. Native queries and the second-level cache ignore filters entirely. Anything written as @Query(nativeQuery = true) is your own problem: the predicate has to be in the SQL. And cached query results are stored without the filter applied, so a query cache shared across tenants is a leak by construction. Raaqib runs without a second-level cache for exactly that reason.

Testing that it actually holds

An isolation guarantee you don't test is a guess. My integration tests run against real PostgreSQL through Testcontainers rather than H2, because H2 doesn't behave like Postgres and I'd rather my tests lie to me less. The core test is deliberately blunt: seed two tenants with overlapping data, bind tenant A, then assert every read path comes back empty for tenant B's IDs.

@Test
void tenantCannotReadAnotherTenantsProduct() {
    UUID otherProductId = seedProductFor(TENANT_B);

    TenantContext.set(TENANT_A);

    assertThat(productRepository.findScopedById(otherProductId, TENANT_A)).isEmpty();
    assertThat(productRepository.findAll())
        .extracting(Product::getId)
        .doesNotContain(otherProductId);
}

The test I found most valuable, though, was a structural one: reflectively walk every @Entity in the persistence unit and fail the build if it doesn't extend TenantEntity and isn't on an explicit allowlist of genuinely global tables. It catches the real failure mode, which isn't a clever attack — it's a new entity added six months from now by someone who never read this post.

What I'd revisit

At Raaqib's scale this is the right call: one schema, instant onboarding, isolation enforced in one place. If a tenant ever needs a hard compliance guarantee — isolation the database enforces rather than the application — the upgrade path is PostgreSQL row-level security, which pushes the same predicate down to where a forgotten filter can't route around it. Same column, same data model, stronger enforcement.

Until then, the thing that keeps me comfortable isn't the annotation. It's the test that fails when someone forgets it.

Have a question about this?

Get in touch