Multi-Tenancy
Use when implementing tenant resolution, database or schema isolation, tenant-aware JPA, reactive tenant context, migrations, caching, jobs, or authorization in Spring Boot 3.
View SKILL.mdTreat tenant identity as an authorization boundary, not a query convenience.
Choose an isolation model
- Database per tenant: strongest isolation and highest operational cost.
- Schema per tenant: strong logical isolation with shared infrastructure.
- Shared schema with
tenant_id: simplest operations, but every access path must enforce scope. - Document the selected model and prohibit repositories from bypassing it.
Resolve tenant identity
- Derive the tenant from a verified token claim, trusted host mapping, or authenticated API key.
- Reject missing, unknown, disabled, or conflicting tenant identifiers.
- Never trust a public
X-Tenant-Idheader by itself. - Clear servlet thread-local context in
finally; use ReactorContextfor reactive flows.
Enforce isolation
- Apply tenant selection before opening the persistence session or transaction.
- Include tenant identity in unique constraints, cache keys, idempotency keys, and object storage paths.
- Prevent cross-tenant joins and unrestricted administrative repositories.
- Authorize support impersonation explicitly and audit every use.
Operations
- Run migrations per database/schema with resumable progress and version reporting.
- Propagate tenant identity into scheduled jobs, messages, and async tasks explicitly.
- Limit noisy tenants with quotas and per-tenant observability using bounded identifiers.
- Test negative cross-tenant access, not only successful tenant queries.
Examples
- See
examples/good-tenant-filter.javaandexamples/bad-tenant-filter.java.
Executable shared-schema example
The TenantOrderStore and schema implement tenant-scoped reads, creates, updates and deletes. This example uses blocking JDBC, a verified resource-server JWT and an active-tenant registry. It is not a database/schema-routing or reactive implementation. Preserve the application's existing ID types and isolation model rather than copying this UUID model indiscriminately.
Wire TenantScopeFilter into the security chain before
AuthorizationFilter (after bearer authentication and exception translation). Supply a
RequestMatcher for only the tenant-protected routes and a server-side predicate checking the
tenant registry. Instantiate the filter inside the chain configuration, or disable its servlet
auto-registration if it is a bean, so it does not run twice. Missing authentication is 401;
missing, disabled or unknown tenant membership is 403. Do not read a public tenant header.
The verified tenant_id claim must be issued under a trusted tenant-membership policy; where
revocation must take effect before token expiry, consult current membership as well as the
active-tenant registry. Background jobs must similarly re-authorize tenant activity/membership
at execution time. A captured ID is context propagation, not perpetual authorization.
TenantContext scopes work with try/finally cleanup, preserves nested scopes and provides tenant-qualified cache keys:
TenantId tenant = TenantAccess.resolve(authentication, tenantRegistry::isActive);
try (var scope = TenantContext.open(tenant)) {
store.find(orderId);
String key = TenantContext.orderCacheKey(orderId);
executor.execute(TenantContext.capture(() -> store.find(orderId)));
}
Open the tenant scope before invoking a separate transactional service proxy. The example
does not propagate a transaction or SecurityContext to the worker; it starts its own unit of
work. Do not use InheritableThreadLocal with pooled workers. Servlet async dispatches need
explicit context propagation too. Scope every list, join and native query, and include
tenant_id in child foreign keys. No Java repository wrapper protects against arbitrary
unscoped SQL: restrict database credentials and consider PostgreSQL row-level security as
defense in depth.
Verify same-ID records in two tenants, forbidden cross-tenant reads/updates/deletes, distinct cache keys, missing-context rejection, exception cleanup and sequential jobs on one worker.
Verification scope
The repository fixture targets Boot 3.5.16 with Java 17/21 and checks PostgreSQL 16 shared-schema access and blocking thread context; not Hibernate schema routing or Reactor. Check the coverage matrix before treating another minor version or an illustrative snippet as verified.
Gotchas
- Agent trusts a tenant header supplied by the caller - derive tenant from authenticated context.
- Agent forgets to clear a servlet
ThreadLocal- pooled threads can leak one tenant into another request. - Agent scopes database queries but not cache keys - cached data can cross tenants.
- Agent starts a transaction before selecting the tenant - routing may choose the wrong database.
- Agent runs background jobs without tenant context - make tenant an explicit job parameter.
Examples & templates
bad-tenant-filter.javaexamples
String tenant = request.getHeader("X-Tenant-Id");
TenantContext.set(tenant);
filterChain.doFilter(request, response);good-tenant-filter.javaexamples
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
final class TenantContextFilter extends OncePerRequestFilter {
private final TenantResolver tenantResolver;
TenantContextFilter(TenantResolver tenantResolver) {
this.tenantResolver = tenantResolver;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String tenantId = tenantResolver.fromVerifiedAuthentication(authentication);
try {
TenantContext.set(tenantId);
filterChain.doFilter(request, response);
}
finally {
TenantContext.clear();
}
}
}tenant-orders.sqlexamples
CREATE TABLE tenant_orders (
tenant_id VARCHAR(128) NOT NULL,
id UUID NOT NULL,
description TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
);TenantAccess.javatemplates
package com.example.tenancy;
import java.util.function.Predicate;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
public final class TenantAccess {
private TenantAccess() {}
public static TenantId resolve(Authentication authentication, Predicate<String> activeTenant) {
if (!(authentication instanceof JwtAuthenticationToken token) || !token.isAuthenticated()) {
throw new InsufficientAuthenticationException("Authenticated JWT required");
}
// Only a claim from the resource server's verified, issuer/audience-checked token.
Object claim = token.getToken().getClaims().get("tenant_id");
if (!(claim instanceof String value) || value.isBlank() || value.length() > 128
|| !activeTenant.test(value)) {
throw new AccessDeniedException("Tenant unavailable");
}
return new TenantId(value);
}
}TenantContext.javatemplates
package com.example.tenancy;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
import java.util.UUID;
/** Servlet/blocking example only. Reactor applications must use Reactor Context instead. */
public final class TenantContext {
private static final ThreadLocal<TenantId> CURRENT = new ThreadLocal<>();
private TenantContext() {}
public static TenantId required() {
TenantId tenant = CURRENT.get();
if (tenant == null) throw new IllegalStateException("Tenant context required");
return tenant;
}
public static Scope open(TenantId tenant) {
TenantId previous = CURRENT.get();
CURRENT.set(Objects.requireNonNull(tenant));
return () -> {
if (previous == null) CURRENT.remove();
else CURRENT.set(previous);
};
}
public static Runnable capture(Runnable task) {
TenantId tenant = required(); // Capture at submission, never resolve on a pooled worker.
return () -> {
try (var ignored = open(tenant)) {
task.run();
}
};
}
public static String orderCacheKey(UUID id) {
String tenant = Base64.getUrlEncoder().withoutPadding()
.encodeToString(required().value().getBytes(StandardCharsets.UTF_8));
return "orders:v1:tenant:" + tenant + ":order:" + id;
}
@FunctionalInterface
public interface Scope extends AutoCloseable {
@Override void close();
}
}TenantId.javatemplates
package com.example.tenancy;
public record TenantId(String value) {
public TenantId {
if (value == null || value.isBlank() || value.length() > 128) {
throw new IllegalArgumentException("Tenant ID must be nonblank and at most 128 characters");
}
}
}TenantOrderStore.javatemplates
package com.example.tenancy;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
/** Shared-schema repository; no public unscoped read/write operation. */
public final class TenantOrderStore {
private final JdbcTemplate jdbc;
public TenantOrderStore(JdbcTemplate jdbc) { this.jdbc = jdbc; }
public void create(UUID id, String description) {
jdbc.update("INSERT INTO tenant_orders (tenant_id, id, description) VALUES (?, ?, ?)",
TenantContext.required().value(), id, description);
}
public Optional<Order> find(UUID id) {
return jdbc.query("SELECT id, description FROM tenant_orders WHERE tenant_id = ? AND id = ?",
(rs, row) -> new Order(rs.getObject("id", UUID.class), rs.getString("description")),
TenantContext.required().value(), id).stream().findFirst();
}
public boolean update(UUID id, String description) {
return jdbc.update("UPDATE tenant_orders SET description = ? WHERE tenant_id = ? AND id = ?",
description, TenantContext.required().value(), id) == 1;
}
public boolean delete(UUID id) {
return jdbc.update("DELETE FROM tenant_orders WHERE tenant_id = ? AND id = ?",
TenantContext.required().value(), id) == 1;
}
public record Order(UUID id, String description) {}
}TenantScopeFilter.javatemplates
package com.example.tenancy;
import java.io.IOException;
import java.util.function.Predicate;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
public final class TenantScopeFilter extends OncePerRequestFilter {
private final RequestMatcher routes;
private final Predicate<String> activeTenant;
public TenantScopeFilter(RequestMatcher routes, Predicate<String> activeTenant) {
this.routes = routes;
this.activeTenant = activeTenant;
}
@Override protected boolean shouldNotFilter(HttpServletRequest request) {
return !routes.matches(request);
}
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
TenantId tenant = TenantAccess.resolve(
SecurityContextHolder.getContext().getAuthentication(), activeTenant);
try (var ignored = TenantContext.open(tenant)) {
chain.doFilter(request, response);
}
}
}Verification scope
- Compiled sources
- Shared-schema templates, both trees
- Runtime checks
- PostgreSQL reads/writes, cache-key isolation, filter cleanup and pooled task propagation
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 3 skills
Install Boot 3 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-3-skills@spring-boot-skills