DEV Community

Mahmoud Farouk
Mahmoud Farouk

Posted on Fully Autonomous

Hibernate filters don't apply to findById: a multi-tenancy leak in Spring Boot 4

Single-schema multi-tenancy with a Hibernate filter is a popular pattern. Every tenant table has a tenant_id column, a @Filter adds tenant_id = :tenantId to every query, and an aspect turns the filter on for each request. Lists, searches and reports are all isolated, and the tests for them pass.

There is one hole in it that is easy to miss, and my own starter kit had it. This is how it works and how version 5.3 closes it in two layers.

The gap described here is fixed in Arab Enterprise Kit 5.3, and the update is free for every v5.x license.

The hole

Spring Data's SimpleJpaRepository.findById calls EntityManager.find. Hibernate filters apply to queries (HQL, JPQL, Criteria and filtered collections). They do not apply to loading an entity by its primary key.

// Looks safe, is not: EntityManager.find ignores the tenant filter
Invoice invoice = invoiceRepository.findById(42L).orElseThrow();
Enter fullscreen mode Exit fullscreen mode

So GET /api/invoices/42 sent by a user of company A can load company B's invoice, as long as A knows or guesses the id. Every list endpoint still looks perfectly isolated, because lists go through queries. A test suite that only checks listings will never see it.

deleteById goes through findById, so it has the same gap.

Layer 1: make findById a query

The fix at the repository level is to run the lookup as a JPQL query, so the filter applies. The kit does it once, in a repository base class:

public class TenantAwareRepository<T, ID> extends SimpleJpaRepository<T, ID> {

    private final EntityManager entityManager;
    private final JpaEntityInformation<T, ?> entityInformation;

    public TenantAwareRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
        super(entityInformation, entityManager);
        this.entityManager = entityManager;
        this.entityInformation = entityInformation;
    }

    @Override
    public Optional<T> findById(ID id) {
        if (id == null) {
            throw new IllegalArgumentException("The given id must not be null");
        }
        String idName = entityInformation.getRequiredIdAttribute().getName();
        String jpql = "select e from " + entityInformation.getEntityName() + " e where e." + idName + " = :id";
        return entityManager.createQuery(jpql, getDomainClass())
                .setParameter("id", id)
                .getResultStream()
                .findFirst();
    }
}
Enter fullscreen mode Exit fullscreen mode

and registers it for every repository:

@EnableJpaRepositories(repositoryBaseClass = TenantAwareRepository.class)
Enter fullscreen mode Exit fullscreen mode

Because deleteById calls findById, deletes are covered too. Entities that have no tenant filter (subscription plans, for example) behave exactly as before.

Layer 2: an explicit check in the services

A repository base class is easy to bypass later: someone adds a native query or calls the EntityManager directly. So the services that load tenant data by id also check ownership:

public final class TenantGuard {

    private TenantGuard() {}

    public static boolean ownedByCurrentTenant(BaseEntity entity) {
        return ownedByCurrentTenant(entity.getTenantId());
    }

    public static boolean ownedByCurrentTenant(String entityTenantId) {
        String tenantId = TenantContext.getTenantId();
        return tenantId != null && tenantId.equals(entityTenantId);
    }
}
Enter fullscreen mode Exit fullscreen mode
Invoice existing = invoiceRepository.findById(id)
        .filter(TenantGuard::ownedByCurrentTenant)
        .orElseThrow(() -> new IllegalArgumentException("Invoice not found: " + id));
Enter fullscreen mode Exit fullscreen mode

Note the error: another tenant's invoice looks exactly like a missing one. Answering "forbidden" would tell the caller that id 42 exists.

Fail closed when there is no tenant

The aspect that enables the filter has one more decision to make: what happens when no tenant is set at all? A scheduled job or a background thread that forgets to set it should not see every tenant's data.

if (tenantId == null) {
    if (isTenantAgnostic(joinPoint)) return;
    tenantId = NO_TENANT; // "__no_tenant__" matches no row
}
session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
Enter fullscreen mode Exit fullscreen mode

The few services that really run without a tenant (sign-in, registration, payment webhooks that read the tenant from the payload) opt out with a @TenantAgnostic annotation. Everything else sees nothing instead of everything.

The tests that hold it in place

The kit's isolation tests seed two tenants and check each path, not just the listing:

  • listing as tenant A does not return tenant B's items
  • reading tenant B's item by id as tenant A is rejected
  • updating it by id is rejected and leaves it unchanged
  • deleting it by id is rejected and keeps the item
  • a background thread with tenant B's context sees only tenant B's items
  • a background thread with no tenant context sees no tenant data

If your isolation tests only cover the first line, they would not have caught this.

A checklist for your own code

  • Search for findById, getReferenceById, deleteById and any direct EntityManager.find or getReference on tenant entities.
  • Decide where the guarantee lives: a repository base class, a service check, or both.
  • Make "no tenant" mean "no data".
  • Write the by-id tests, not only the list tests.

Talk to me

This is the first post in a series about building Arab Enterprise Kit, an Arabic-first, multi-tenant SaaS foundation in Java 25, Spring Boot 4 and Angular 21. There's a free Lite edition on GitHub, and the full kit is on Gumroad. I build SaaS products and take freelance full-stack and backend projects: mahmoud-farouk-portfolio.vercel.app.

How does your multi-tenant app handle lookups by id?

Top comments (0)