Spring Boot Skills
GitHub
Architecture

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.md

Treat 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-Id header by itself.
  • Clear servlet thread-local context in finally; use Reactor Context for 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.java and examples/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.
FROM THE REPOSITORY

Examples & templates

bad-tenant-filter.javaexamples
Source on GitHub
String tenant = request.getHeader("X-Tenant-Id");
TenantContext.set(tenant);
filterChain.doFilter(request, response);
good-tenant-filter.javaexamples
Source on GitHub
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
Source on GitHub
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
Source on GitHub
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
Source on GitHub
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
Source on GitHub
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
Source on GitHub
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
Source on GitHub
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 evidence
ADD TO YOUR AGENT

Install Boot 3 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skills