Giving every ISP tenant their own subdomain — acme.ggispbilling.com, fiberco.ggispbilling.com — reads as a branding decision, but it's really a security boundary decision. Every request now carries its tenant identity for free, in the Host header, before a single line of application code runs. The risk is that this convenience makes it tempting to resolve tenant context once near the top of the stack and then trust it implicitly everywhere downstream, which is exactly where leakage bugs live.
Resolving tenant context from the Host header
The first hop is a servlet filter, registered ahead of Spring Security's filter chain, that reads the Host header, strips the known base domain, and resolves the remaining label to a tenant record:
public class TenantResolutionFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String host = req.getServerName(); // e.g. acme.ggispbilling.com
String slug = extractSubdomain(host); // "acme"
Tenant tenant = tenantService.findBySlugOrThrow(slug);
TenantContext.set(tenant.getId()); // ThreadLocal, cleared in finally
try {
chain.doFilter(req, res);
} finally {
TenantContext.clear();
}
}
}
TenantContext is a ThreadLocal, which is the right tool here precisely because it's strictly request-scoped — it can't accidentally bleed into another request handled by a different thread, as long as it's cleared in a finally block every single time, including on exceptions. Forgetting the finally clear is the first leakage bug most teams ship.
Carrying tenant identity through authentication
Once a user authenticates, the JWT issued to them should embed the tenant id as a claim, not just the user id. This matters because the subdomain alone isn't sufficient proof of tenant membership — without the claim check, nothing stops a valid token issued for tenant A from being replayed against tenant B's subdomain if an attacker can guess or observe a valid token format. A custom OncePerRequestFilter after standard JWT validation should additionally assert that the token's tenant claim matches the tenant resolved from the Host header, and reject the request outright if they disagree:
if (!jwtTenantId.equals(TenantContext.get())) {
throw new AccessDeniedException("Token tenant does not match request subdomain");
}
This single check closes an entire class of cross-tenant token replay issues, and it's cheap enough to run on every authenticated request.
Database isolation: shared schema with a guard rail
A shared schema with a tenant_id column on every tenant-owned table is the pragmatic choice for most multi-tenant SaaS — schema-per-tenant or database-per-tenant scale operationally worse long before they pay off in isolation guarantees. The risk with shared schema is exactly what you'd expect: a missing WHERE tenant_id = ? clause anywhere returns another tenant's rows.
JPA/Hibernate's @Filter mechanism, enabled per-session and parameterized from the same TenantContext, closes most of this automatically for anything going through the ORM:
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Entity
public class Customer { ... }
// enabled once per request, alongside TenantContext being set
session.enableFilter("tenantFilter").setParameter("tenantId", TenantContext.get());
The gap this doesn't cover is native SQL — @Query(nativeQuery = true), JDBC templates, reporting queries, anything that bypasses the entity layer. Hibernate filters don't apply there, and it's where tenant leakage bugs most often actually ship, because they look like ordinary, working SQL in code review. The only durable fix is a written rule, enforced in review: every native query touching a tenant-owned table adds tenant_id = :tenantId explicitly, with no exceptions for "just a quick report."
Caching is the other quiet leak
Application-level caches (Spring Cache, Caffeine, Redis) keyed only by an entity id rather than by (tenantId, entityId) will happily return tenant A's cached customer record when tenant B requests an id that happens to collide numerically across tenants — which is common when ids are simple auto-increment sequences shared across the whole schema. Every cache key in a multi-tenant system should include the tenant id as part of the key, full stop, even when it feels redundant for data that's "obviously" tenant-scoped by relation.
What to actually test
Unit tests rarely catch tenant leakage because they tend to test one tenant's data at a time. The tests that catch real bugs are the ones that deliberately set up two tenants with overlapping ids and confirm tenant A's session genuinely cannot read, list, or mutate tenant B's rows — through the API, not just through the repository layer, since a permissive controller can still leak data even when the underlying query is correctly scoped. Running that class of test as part of CI, against every new tenant-owned entity, catches the leakage bugs before they reach production rather than after a customer notices.