Spring Security for Multi-Tenant Applications — Isolating Data by Tenant in Filters, Queries, and Cache

by Eric Hanson, Backend Developer at Clean Systems Consulting

The tenant isolation requirement

Multi-tenancy means one application instance serves multiple customers (tenants), with each tenant's data invisible to others. The isolation guarantee must hold at every layer:

  • Request layer: identify which tenant this request belongs to
  • Application layer: propagate tenant context through the call stack without requiring every method to pass it explicitly
  • Database layer: ensure queries only return data belonging to the current tenant
  • Cache layer: ensure cached data from one tenant isn't served to another

A gap at any layer is a data leakage vulnerability. Tenant isolation cannot be enforced only in business logic — it must be structural.

Tenant resolution strategies

Three common approaches for identifying the tenant from a request:

Subdomain: acme.example.com → tenant acme, globex.example.com → tenant globex Path segment: /api/tenants/acme/orders JWT claim: tenant_id: "acme" in the access token

Subdomain-based resolution is the most common for SaaS applications. JWT-based resolution is appropriate when the authorization server has already identified the tenant. Path-based is explicit but requires clients to include the tenant in every URL.

ThreadLocal for tenant context propagation

The cleanest pattern for propagating tenant context through the call stack without polluting every method signature: a ThreadLocal holder that's set at the request boundary and cleared at the end:

public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getTenantId() {
        String tenantId = CURRENT_TENANT.get();
        if (tenantId == null) {
            throw new TenantNotSetException("No tenant context established for this request");
        }
        return tenantId;
    }

    public static Optional<String> optionalTenantId() {
        return Optional.ofNullable(CURRENT_TENANT.get());
    }

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

ThreadLocal.remove() in a finally block is mandatory. In thread pool environments, threads are reused — a thread-local set during request A will leak into request B if not cleared. This is both a correctness bug (wrong tenant) and a security bug (data leakage).

Tenant resolution filter

A servlet filter resolves the tenant early in the request lifecycle and sets the TenantContext:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)  // after security filters, before controllers
public class TenantResolutionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        try {
            String tenantId = resolveTenantId(request);
            TenantContext.setTenantId(tenantId);
            chain.doFilter(request, response);
        } catch (TenantResolutionException e) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("{\"error\":\"tenant_resolution_failed\"}");
        } finally {
            TenantContext.clear();  // always clear — even on exception
        }
    }

    private String resolveTenantId(HttpServletRequest request) {
        // Strategy 1: subdomain
        String host = request.getServerName();
        String subdomain = host.split("\\.")[0];
        if (!subdomain.equals("api") && !subdomain.equals("www")) {
            return subdomain;
        }

        // Strategy 2: JWT claim (after Spring Security filter runs)
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof JwtAuthenticationToken jwtAuth) {
            String tenantId = jwtAuth.getToken().getClaimAsString("tenant_id");
            if (tenantId != null) return tenantId;
        }

        // Strategy 3: request header (for internal service-to-service calls)
        String tenantHeader = request.getHeader("X-Tenant-ID");
        if (tenantHeader != null) return tenantHeader;

        throw new TenantResolutionException("Cannot resolve tenant from request");
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.startsWith("/actuator/") || path.equals("/api/health");
    }
}

The filter runs after Spring Security's authentication filters (which populate SecurityContextHolder) but before the DispatcherServlet. shouldNotFilter excludes health and actuator endpoints that don't have tenant context.

Database isolation — Hibernate filters

The most reliable database isolation strategy is Hibernate's filter mechanism. A Hibernate filter adds a WHERE clause to every query on annotated entities:

// Define the filter on the entity
@Entity
@Table(name = "orders")
@FilterDef(
    name = "tenantFilter",
    parameters = @ParamDef(name = "tenantId", type = String.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {
    @Id private Long id;
    @Column(name = "tenant_id", nullable = false)
    private String tenantId;
    // ... other fields
}

Enable the filter for each session via EntityManager:

@Component
public class TenantFilterActivator {

    @PersistenceContext
    private EntityManager entityManager;

    @PostConstruct
    public void enableTenantFilter() {
        // This approach doesn't work — EntityManager is request-scoped
        // See the aspect approach below
    }
}

The filter must be enabled per Hibernate session. An @Aspect that activates the filter at the start of each transaction is the cleanest mechanism:

@Aspect
@Component
public class TenantFilterAspect {

    @PersistenceContext
    private EntityManager entityManager;

    @Before("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void activateTenantFilter() {
        TenantContext.optionalTenantId().ifPresent(tenantId -> {
            Session session = entityManager.unwrap(Session.class);
            session.enableFilter("tenantFilter")
                .setParameter("tenantId", tenantId);
        });
    }
}

With this aspect, every @Transactional method automatically has the tenant filter applied. Queries on Order include WHERE tenant_id = 'acme' — not because the repository method specifies it, but because Hibernate adds it automatically.

The filter bypass. Hibernate filters are bypassed by:

  • entityManager.find() (loads by primary key — the filter is not applied)
  • Native SQL queries
  • @Modifying @Query with native SQL
  • JDBC JdbcTemplate queries

For entityManager.find(), verify tenant ownership explicitly:

public Order findOrderForCurrentTenant(Long orderId) {
    Order order = entityManager.find(Order.class, orderId);
    if (order == null || !order.getTenantId().equals(TenantContext.getTenantId())) {
        throw new OrderNotFoundException(orderId);
    }
    return order;
}

For native SQL, add tenant_id = :tenantId to the query manually.

Database isolation — row-level security

PostgreSQL's Row Level Security (RLS) provides tenant isolation at the database level, independent of the application:

-- Enable RLS on the table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Create policy: users can only see their tenant's rows
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant'));

The application sets the session variable before executing queries:

@Component
public class TenantDatabaseInterceptor implements StatementInspector {

    @Override
    public String inspect(String sql) {
        TenantContext.optionalTenantId().ifPresent(tenantId ->
            // Set PostgreSQL session variable before each statement
            // This requires a custom JDBC connection wrapper or a Hibernate interceptor
        );
        return sql;
    }
}

A simpler approach: a ConnectionCustomizer that sets the session variable when a connection is acquired from the pool:

@Bean
public DataSource dataSource(DataSourceProperties properties) {
    HikariDataSource ds = properties.initializeDataSourceBuilder()
        .type(HikariDataSource.class).build();

    ds.setConnectionInitSql(
        "SET app.current_tenant = ''; -- initialized, actual value set per request"
    );

    // Use a connection customizer to set the tenant per request
    ds.setConnectionCustomizer(conn -> {
        String tenantId = TenantContext.optionalTenantId().orElse("");
        try (var stmt = conn.prepareStatement("SET app.current_tenant = ?")) {
            stmt.setString(1, tenantId);
            stmt.execute();
        }
    });

    return ds;
}

RLS at the database level is the most robust isolation — even if the application code has a bug (misses a filter, uses native SQL), the database enforces isolation. The tradeoff: it's PostgreSQL-specific and requires database-level configuration.

Cache isolation — tenant-namespaced keys

An application-level cache without tenant awareness is a data leakage vector. A product cached under key product:123 for tenant acme will be served to tenant globex if they request the same product ID.

Namespace all cache keys with the tenant ID:

@Configuration
public class TenantAwareCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .computePrefixWith(cacheName ->
                TenantContext.optionalTenantId()
                    .map(tenantId -> tenantId + ":" + cacheName + ":")
                    .orElse(cacheName + ":")
            );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }
}

Cache keys become acme:products:123 and globex:products:123 — isolated by tenant. Eviction must also be tenant-scoped: @CacheEvict on products evicts {currentTenant}:products:{key}, which is the correct behavior when TenantContext is set during eviction.

For @Cacheable to work correctly, it must be called within a context where TenantContext.getTenantId() returns the correct tenant:

@Service
public class ProductService {

    @Cacheable("products")  // key becomes "{tenantId}:products:{productId}"
    public Product getProduct(Long productId) {
        return productRepository.findById(productId).orElseThrow();
    }
}

The cache prefix is computed at cache access time using TenantContext.getTenantId(). If TenantContext is not set (in background jobs, for example), the prefix falls back to the cache name without a tenant prefix — adjust the fallback based on your background job strategy.

Background job tenant context

Background jobs (Sidekiq-style, @Scheduled, message consumers) run outside the request lifecycle — no servlet filter sets the TenantContext. The tenant must be encoded in the job payload and set explicitly:

@Component
public class OrderProcessingJob {

    @KafkaListener(topics = "orders.placed")
    public void processOrder(OrderPlacedEvent event) {
        TenantContext.setTenantId(event.tenantId()); // tenant from the event payload
        try {
            orderService.processOrder(event.orderId());
        } finally {
            TenantContext.clear();
        }
    }
}

Every background job that accesses tenant data must set and clear TenantContext. A job that processes events from multiple tenants must set the correct tenant before each unit of work:

@Scheduled(fixedDelay = 60_000)
public void processExpiredOrders() {
    List<String> tenantIds = tenantRegistry.getAllTenantIds();
    tenantIds.forEach(tenantId -> {
        TenantContext.setTenantId(tenantId);
        try {
            orderService.expireOrders();
        } finally {
            TenantContext.clear();
        }
    });
}

Testing multi-tenant isolation

The critical tests verify that data from one tenant is not accessible to another:

@SpringBootTest
@Testcontainers
class TenantIsolationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired OrderRepository orderRepository;
    @Autowired OrderService orderService;

    @Test
    void ordersBelongToCorrectTenant() {
        // Create orders for two tenants
        TenantContext.setTenantId("acme");
        Order acmeOrder = orderService.createOrder(new CreateOrderRequest(...));
        TenantContext.clear();

        TenantContext.setTenantId("globex");
        Order globexOrder = orderService.createOrder(new CreateOrderRequest(...));

        // Verify isolation
        List<Order> globexOrders = orderService.findOrders();
        assertThat(globexOrders).containsOnly(globexOrder);
        assertThat(globexOrders).doesNotContain(acmeOrder);
        TenantContext.clear();
    }

    @Test
    void directAccessByIdIsRejected_forWrongTenant() {
        TenantContext.setTenantId("acme");
        Order acmeOrder = orderService.createOrder(new CreateOrderRequest(...));
        Long acmeOrderId = acmeOrder.getId();
        TenantContext.clear();

        TenantContext.setTenantId("globex");
        assertThatThrownBy(() -> orderService.findOrder(acmeOrderId))
            .isInstanceOf(OrderNotFoundException.class);
        TenantContext.clear();
    }
}

These tests verify isolation at the application level. Add equivalent tests at the database level (direct JdbcTemplate queries) if using Hibernate filters rather than RLS, to catch any bypass paths.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Observability: The Missing Piece in Many Startups

Everything works… until it doesn’t. And when it breaks, most startups realize they have no idea what’s actually happening.

Read more

Why Employee Monitoring Tools Are Not Necessary for Remote Teams

Trust beats tracking. Remote teams thrive on autonomy, not constant surveillance.

Read more

When You Merge Into Main by Mistake

Accidental merges happen to the best of us. Here’s how to handle it without causing chaos or losing sleep.

Read more

When It is Okay to Leave a Meeting Without Asking Permission

Sometimes, sitting through a meeting feels like watching paint dry. Not every minute in a calendar invite deserves your attention—and that’s okay.

Read more