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 @Querywith native SQL- JDBC
JdbcTemplatequeries
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.