- Published on
- · Updated
Secure Multi-Tenancy with Spring Boot, Hibernate, and PostgreSQL
- Authors

- Name
- Maria
Multi-tenancy is not primarily a database-routing feature. It is a security boundary.
A SaaS application may share compute, connection pools, tables, schemas, or even database clusters across customers. None of those choices matter if one request can accidentally read or modify another tenant's data.
A production design must answer:
- Where does the tenant identity come from?
- Which layer rejects a missing or unauthorized tenant?
- Does every ORM query include the tenant boundary?
- What happens to native SQL, batch jobs, caches, and Kafka consumers?
- Can a pooled PostgreSQL connection retain the previous tenant's state?
- How are tenant-specific backups, migrations, exports, and deletions performed?
- Which controls still protect data when application code contains a bug?
This guide uses Spring Boot 4.1, Java 25, Hibernate's tenant support, and PostgreSQL row-level security.
TL;DR Do not trust a tenant header by itself. Resolve the tenant from the authenticated principal and verified membership. For shared tables, use Hibernate
@TenantIdfor ORM filtering and PostgreSQL row-level security as a database backstop. Never fall back to a public or default tenant when context is missing. Include the tenant in cache keys, events, constraints, tests, and operational workflows.
Choose an Isolation Model Deliberately
There is no universally best topology.
Database per tenant
Each tenant has a separate database or database cluster.
Advantages:
- strongest physical isolation;
- tenant-specific backup, restore, and migration;
- easier placement for residency requirements;
- large tenants can scale independently.
Costs:
- many connection pools;
- higher provisioning and monitoring overhead;
- cross-tenant reporting needs a separate analytics path;
- schema rollout must be coordinated across databases.
This model is appropriate for large tenants, regulated data, or contractual isolation requirements.
Schema per tenant
Tenants share one PostgreSQL database but use separate schemas.
Advantages:
- stronger namespace isolation than shared tables;
- one database cluster can serve many tenants;
- tenant-specific schema export is possible.
Costs:
- every migration must run across every tenant schema;
- catalog size and migration duration grow with tenant count;
- all tenants still share CPU, memory, I/O, and connections;
- unsafe
search_pathhandling can select the wrong objects.
Hibernate 7 supports schema-based multi-tenancy through a TenantSchemaMapper. Tenant IDs should map through a trusted registry to server-controlled schema names. Never concatenate a raw request header into SET search_path.
Shared schema with a tenant discriminator
All tenants share the same tables, and each tenant-owned row contains tenant_id.
Advantages:
- simple provisioning;
- one migration per table;
- efficient resource pooling;
- suitable for many small and medium tenants.
Costs:
- one missing tenant predicate can expose data;
- indexes and uniqueness constraints must include tenant ownership;
- tenant-specific restore and deletion are more complex;
- noisy-neighbor control requires additional design.
This guide implements the shared-schema approach because it makes the isolation controls and failure modes easy to demonstrate.
Hybrid placement
Many mature systems use tiers:
small tenants -> shared tables
large tenants -> dedicated database
regulated data -> regional dedicated database
The application resolves a tenant to a placement record rather than assuming every tenant uses the same storage strategy.
Tenant Identity Is an Authorization Decision
A client-controlled header is not proof of tenant membership.
Unsafe:
X-Tenant-ID: another-company
A safer request flow is:
Authenticate user or service
-> read immutable subject and tenant claims
-> verify active membership and role
-> select one authorized tenant
-> establish tenant context
-> begin database transaction
For browser and API requests, the tenant usually comes from:
- a signed OIDC/JWT claim;
- a server-side session;
- an authenticated subdomain mapped by the server;
- an explicit tenant selector checked against memberships.
An internal gateway may forward a tenant header, but the application should trust it only when the caller is authenticated through an internal security boundary such as mTLS and the header cannot be supplied directly by an external client.
Use a Stable Tenant Identifier
Use an immutable identifier such as UUID.
public record TenantId(UUID value) {
public TenantId {
Objects.requireNonNull(value);
}
public static TenantId parse(String raw) {
try {
return new TenantId(
UUID.fromString(raw)
);
} catch (IllegalArgumentException exception) {
throw new InvalidTenantException(raw);
}
}
@Override
public String toString() {
return value.toString();
}
}
Do not use a company name, subdomain, or mutable slug as the database ownership key. Store those as attributes mapped to the stable tenant ID.
Tenant Context Without a Default Fallback
There is no VirtualThreadLocal class in Java. Virtual threads support ordinary ThreadLocal values. The same cleanup and asynchronous propagation rules still apply.
A scoped wrapper makes accidental leakage less likely:
public final class TenantContext {
private static final ThreadLocal<TenantId> CURRENT =
new ThreadLocal<>();
private TenantContext() {}
public static Scope open(TenantId tenantId) {
Objects.requireNonNull(tenantId);
TenantId previous = CURRENT.get();
CURRENT.set(tenantId);
return () -> {
if (previous == null) {
CURRENT.remove();
} else {
CURRENT.set(previous);
}
};
}
public static TenantId requireTenant() {
TenantId tenantId = CURRENT.get();
if (tenantId == null) {
throw new MissingTenantContextException();
}
return tenantId;
}
@FunctionalInterface
public interface Scope extends AutoCloseable {
@Override
void close();
}
}
Do not return public, default, or a system tenant when context is absent. Missing context should fail before tenant-owned data is accessed.
Java 25 also provides ScopedValue for immutable scoped data. It can be a good choice when the application controls the complete call tree, but framework integration still needs careful design. The important rule is explicit lifetime and no silent fallback.
Establish Context After Authentication
A filter can establish the context from a custom authenticated principal.
public record SaaSPrincipal(
UUID subjectId,
TenantId tenantId,
Set<String> roles
) {}
@Component
public class TenantContextFilter
extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Authentication authentication =
SecurityContextHolder
.getContext()
.getAuthentication();
if (!(authentication.getPrincipal()
instanceof SaaSPrincipal principal)) {
response.sendError(
HttpStatus.UNAUTHORIZED.value()
);
return;
}
try (TenantContext.Scope ignored =
TenantContext.open(
principal.tenantId()
)) {
filterChain.doFilter(
request,
response
);
}
}
}
Register this filter after bearer-token authentication:
@Bean
SecurityFilterChain security(
HttpSecurity http,
TenantContextFilter tenantFilter
) throws Exception {
return http
.oauth2ResourceServer(
oauth -> oauth.jwt(
Customizer.withDefaults()
)
)
.addFilterAfter(
tenantFilter,
BearerTokenAuthenticationFilter.class
)
.authorizeHttpRequests(
requests -> requests
.requestMatchers(
"/actuator/health"
)
.permitAll()
.anyRequest()
.authenticated()
)
.build();
}
Public endpoints should be excluded explicitly rather than executed with a default tenant.
Resolve the Hibernate Tenant
Hibernate can obtain the current tenant from CurrentTenantIdentifierResolver.
@Component
public class HibernateTenantIdentifierResolver
implements CurrentTenantIdentifierResolver<UUID> {
@Override
public UUID resolveCurrentTenantIdentifier() {
return TenantContext
.requireTenant()
.value();
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
Register the resolver explicitly:
@Configuration
public class HibernateTenantConfiguration {
@Bean
HibernatePropertiesCustomizer tenantResolverCustomizer(
HibernateTenantIdentifierResolver resolver
) {
return properties -> properties.put(
AvailableSettings
.MULTI_TENANT_IDENTIFIER_RESOLVER,
resolver
);
}
}
validateExistingCurrentSessions() helps detect a persistence session being reused with a different tenant identifier.
Map Shared Tables with @TenantId
Hibernate supports discriminator-based multi-tenancy using @TenantId.
@Entity
@Table(
name = "projects",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_project_tenant_external_key",
columnNames = {
"tenant_id",
"external_key"
}
)
}
)
public class Project {
@Id
private UUID id;
@TenantId
@Column(
name = "tenant_id",
nullable = false,
updatable = false
)
private UUID tenantId;
@Column(
name = "external_key",
nullable = false
)
private String externalKey;
@Column(nullable = false)
private String name;
@Version
private long version;
protected Project() {}
public static Project create(
UUID id,
String externalKey,
String name
) {
Project project = new Project();
project.id = id;
project.externalKey = externalKey;
project.name = name;
return project;
}
}
Hibernate automatically populates the @TenantId field when the entity becomes persistent and filters ORM-managed access by the current tenant.
Native SQL is not automatically filtered. Bulk SQL, JDBC code, stored procedures, and database administration remain separate isolation paths.
Design Database Constraints Around Tenant Ownership
A global primary key does not make every relationship tenant-safe.
CREATE TABLE projects (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
external_key VARCHAR(100) NOT NULL,
name VARCHAR(250) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
UNIQUE (tenant_id, external_key),
UNIQUE (tenant_id, id)
);
CREATE INDEX idx_projects_tenant_id_id
ON projects (tenant_id, id);
For tenant-owned relationships, include the tenant in the foreign key:
CREATE TABLE tasks (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
project_id UUID NOT NULL,
title VARCHAR(250) NOT NULL,
UNIQUE (tenant_id, id),
CONSTRAINT fk_task_project_same_tenant
FOREIGN KEY (tenant_id, project_id)
REFERENCES projects (tenant_id, id)
);
This prevents a task from pointing to a project owned by another tenant even if application validation is missing.
Add PostgreSQL Row-Level Security
Hibernate filtering is useful, but database-level row-level security provides defense in depth for accidental ORM and SQL mistakes.
Enable RLS:
ALTER TABLE projects
ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects
FORCE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_isolation
ON projects
USING (
tenant_id =
current_setting(
'app.current_tenant',
true
)::uuid
)
WITH CHECK (
tenant_id =
current_setting(
'app.current_tenant',
true
)::uuid
);
Apply equivalent policies to every tenant-owned table.
USING controls which existing rows are visible or targetable. WITH CHECK controls which tenant ID may be inserted or produced by an update.
The application database role must not be:
- a PostgreSQL superuser;
- granted
BYPASSRLS; - unintentionally exempt as table owner without
FORCE ROW LEVEL SECURITY.
Administrative access should use a separate, audited role and connection path.
Bind the Tenant to the Database Transaction
The RLS policy reads a transaction-local PostgreSQL setting.
SELECT set_config(
'app.current_tenant',
'0a51931c-5940-45be-9fe4-305a980e68a7',
true
);
The third argument, true, makes the setting local to the current transaction. PostgreSQL automatically restores the previous value when the transaction ends.
One explicit integration approach is a tenant transaction executor.
@Component
public class TenantTransactionExecutor {
private final TransactionTemplate transactions;
private final JdbcClient jdbc;
private final TenantRegistry tenants;
public TenantTransactionExecutor(
PlatformTransactionManager transactionManager,
JdbcClient jdbc,
TenantRegistry tenants
) {
this.transactions =
new TransactionTemplate(
transactionManager
);
this.jdbc = jdbc;
this.tenants = tenants;
}
public <T> T execute(
TenantId requestedTenant,
Supplier<T> work
) {
TenantId activeTenant =
tenants.requireActive(
requestedTenant
);
try (TenantContext.Scope ignored =
TenantContext.open(
activeTenant
)) {
return transactions.execute(status -> {
bindRlsTenant(activeTenant);
return work.get();
});
}
}
private void bindRlsTenant(
TenantId tenantId
) {
jdbc.sql("""
SELECT set_config(
'app.current_tenant',
:tenant_id,
true
)
""")
.param(
"tenant_id",
tenantId.toString()
)
.query(String.class)
.single();
}
}
The context is established before the transaction and Hibernate session are opened. JdbcClient and JPA must use the same datasource and transaction manager so the tenant setting and ORM work share one transaction-bound connection.
Use the executor at a use-case boundary:
@RestController
@RequestMapping("/projects")
public class ProjectController {
private final TenantTransactionExecutor tenantTransactions;
private final ProjectApplicationService projects;
public ProjectController(
TenantTransactionExecutor tenantTransactions,
ProjectApplicationService projects
) {
this.tenantTransactions =
tenantTransactions;
this.projects = projects;
}
@GetMapping("/{projectId}")
public ProjectView get(
@PathVariable UUID projectId,
Authentication authentication
) {
SaaSPrincipal principal =
(SaaSPrincipal)
authentication.getPrincipal();
return tenantTransactions.execute(
principal.tenantId(),
() -> projects.get(projectId)
);
}
}
A production application may integrate this behavior into its transaction interceptor instead. The important property is that RLS binding occurs once at the start of every tenant transaction and cannot be forgotten by one repository method.
Do Not Use Silent System-Tenant Access
Background jobs and administrative code often create the largest isolation holes.
Unsafe:
if (TenantContext.get() == null) {
return SYSTEM_TENANT;
}
Safer job structure:
@Component
public class TenantInvoiceJob {
private final TenantRegistry tenants;
private final TenantTransactionExecutor transactions;
private final InvoiceService invoices;
@Scheduled(cron = "0 0 2 * * *")
public void generateInvoices() {
tenants.activeTenantIds()
.forEach(tenantId ->
transactions.execute(
tenantId,
() -> {
invoices.generateDue();
return null;
}
)
);
}
}
The job explicitly selects one tenant at a time. Failures can be recorded and retried per tenant.
Cross-tenant administration should use a separate application service, role, and audit trail rather than a magic tenant ID.
Native SQL Needs an Explicit Policy
Hibernate's @TenantId filtering does not apply to native SQL.
Unsafe:
@Query(
value = """
SELECT *
FROM projects
WHERE external_key = :externalKey
""",
nativeQuery = true
)
Optional<Project> findNative(
String externalKey
);
With RLS correctly bound, PostgreSQL still filters the rows. Without RLS, this query can cross tenants.
For explicit SQL, include the tenant predicate as well:
@Query(
value = """
SELECT *
FROM projects
WHERE tenant_id = :tenantId
AND external_key = :externalKey
""",
nativeQuery = true
)
Optional<Project> findNative(
UUID tenantId,
String externalKey
);
Application predicates improve clarity and query planning. RLS remains the backstop.
Review:
- native repository queries;
JdbcClientandJdbcTemplate;- bulk updates and deletes;
- stored procedures;
- database views;
- ETL and export jobs;
- support scripts.
Schema-per-Tenant Safety
Schema-based multi-tenancy can be a good fit for a moderate number of tenants with stronger namespace isolation requirements.
Hibernate 7 uses a TenantSchemaMapper to map a tenant identifier to a schema.
The mapping should come from a trusted registry:
tenant UUID
-> validated placement record
-> server-owned schema name
Never execute:
statement.execute(
"SET search_path TO "
+ request.getHeader("X-Tenant-ID")
);
Problems include:
- SQL injection;
- identifier quoting errors;
- unauthorized schema selection;
- pooled connections retaining tenant state;
- untrusted schemas shadowing functions or objects on
search_path.
Prefer a validated identifier and driver or Hibernate schema-switching facilities. Reset the connection before returning it to the pool, or use transaction-local schema configuration where supported.
Also restrict CREATE privileges. PostgreSQL warns that schemas writable by untrusted users can place malicious objects on a search path.
Database-per-Tenant Routing
Database-per-tenant systems need a placement registry:
public record TenantPlacement(
TenantId tenantId,
String region,
String databaseKey,
PlacementStatus status
) {}
The application should resolve databaseKey to a preconfigured datasource. Do not accept JDBC URLs or credentials from requests.
Operational concerns include:
- maximum connection count across pools;
- lazy pool creation and eviction;
- credential rotation;
- regional placement;
- tenant migration between databases;
- per-database schema version;
- health-check fan-out;
- backup and restore tests.
Creating one full-size Hikari pool for every small tenant can exhaust database connections before the application serves meaningful traffic.
Cache Keys Must Include Tenant Ownership
Unsafe:
project:24d18d4e
Safer:
tenant:{tenantId}:project:{projectId}
public String projectKey(
TenantId tenantId,
UUID projectId
) {
return "tenant:"
+ tenantId
+ ":project:"
+ projectId;
}
This applies to:
- Redis keys;
- Caffeine keys;
- Spring Cache keys;
- query-result caches;
- idempotency keys;
- rate-limit keys;
- distributed locks.
Do not rely on a global entity ID being unique forever as the only cache-isolation control.
Cache invalidation events should carry a validated tenant ID and aggregate ID. Consumers must establish tenant context before accessing tenant-owned data.
Kafka Consumers Need Their Own Tenant Scope
A message envelope can carry tenant ownership:
public record TenantEvent<T>(
UUID eventId,
TenantId tenantId,
String eventType,
int schemaVersion,
Instant occurredAt,
T payload
) {}
Consumer:
@Component
public class ProjectEventListener {
private final TenantRegistry tenants;
private final TenantTransactionExecutor transactions;
private final ProjectProjectionService projections;
@KafkaListener(
topics = "project.events.v1",
groupId = "project-projection-v1"
)
public void onMessage(
TenantEvent<ProjectChanged> event
) {
TenantId tenantId =
tenants.requireActive(
event.tenantId()
);
transactions.execute(
tenantId,
() -> {
projections.apply(event);
return null;
}
);
}
}
Clear context after every record. Do not let one Kafka thread retain a previous tenant when processing the next message.
A tenant header or payload field is not authorization by itself. Trust it only when the producer is authenticated and authorized to publish for that tenant.
Do not create one Kafka topic or consumer group per tenant by default. Thousands of tenant-specific topics increase metadata and operational overhead. Partition by a stable key and include tenant ownership in the event contract unless isolation requirements justify dedicated infrastructure.
Provisioning and Tenant Lifecycle
Provisioning is a workflow, not one INSERT.
Typical steps:
Create tenant record
-> assign placement
-> create database or schema when required
-> apply migrations
-> create initial administrator membership
-> configure encryption and secrets
-> run isolation smoke tests
-> activate tenant
Track provisioning state:
public enum TenantStatus {
PROVISIONING,
ACTIVE,
SUSPENDED,
DELETING,
DELETED,
FAILED
}
Do not route production traffic to a tenant until provisioning is complete.
De-provisioning should define:
- retention and legal-hold requirements;
- export delivery;
- credential revocation;
- cache and search-index deletion;
- event and object-storage cleanup;
- database or schema archival;
- final deletion verification;
- audit records that remain after data removal.
DROP SCHEMA ... CASCADE is not a complete tenant-deletion process when data also exists in Kafka, Redis, search indexes, backups, and external providers.
Migrations
Use Flyway or Liquibase. Do not rely on ddl-auto=update.
Shared schema
One migration changes the shared tables, but mixed application versions must remain compatible.
Use expand-migrate-contract:
add nullable column
deploy compatible code
backfill
validate
enforce constraint
remove old column later
Schema per tenant
Track schema version per tenant and migrate in bounded batches.
A rollout should answer:
- how many schemas can migrate concurrently;
- what happens when tenant 417 fails;
- whether old and new application versions can coexist;
- how long the full fleet migration takes;
- which tenants are blocked from traffic;
- how progress is resumed.
Do not run an unbounded loop that opens hundreds of migrations and database connections at once.
Database per tenant
Maintain a placement and schema-version inventory. A tenant database that missed one migration must not silently rejoin traffic.
Authorization Is Separate from Tenant Isolation
Tenant isolation answers:
Which customer's data may this operation access?
Authorization answers:
What may this subject do inside that tenant?
Both are required.
@PreAuthorize("""
hasAuthority('project:write')
and @tenantAuthorization
.canAccess(authentication, #projectId)
""")
public void updateProject(
UUID projectId,
UpdateProjectCommand command
) {
// ...
}
Do not treat possession of a tenant ID as permission.
A user may belong to several tenants. The selected tenant should be explicit, and every role must be scoped to that tenant membership.
Audit Tenant-Sensitive Actions
An audit record should include:
event ID
tenant ID
subject ID
action
resource type
resource ID
outcome
timestamp
source IP or service identity
trace ID
Do not store secrets or full sensitive payloads in the audit log.
Administrative cross-tenant operations should require stronger controls:
- separate role;
- explicit reason;
- ticket or approval reference;
- time-limited elevation;
- immutable audit record;
- alerting for unusual access.
Observability Without Cardinality Explosion
Tenant context is valuable in logs and traces, but tenant IDs can create high-cardinality metrics.
Useful structured log fields:
tenant.id
subject.id
operation
resource.type
resource.id
outcome
traceId
Use tenant IDs in logs only when policy and retention allow it.
Prometheus labels should usually remain bounded:
service
operation
outcome
placement_tier
region
Avoid one time series per tenant for systems with thousands of tenants. For tenant-level usage and billing, write events to an analytics pipeline rather than using general-purpose operational metrics.
Track:
- authorization failures by category;
- missing tenant context;
- RLS policy violations;
- tenant transaction failures;
- datasource routing failures;
- schema migration backlog;
- pool usage by placement tier;
- noisy-neighbor signals;
- tenant provisioning duration;
- cross-tenant administration events.
Noisy-Neighbor Controls
Shared infrastructure needs explicit limits.
Controls may include:
- API quotas per tenant;
- bounded concurrent jobs;
- Kafka partition and consumer fairness;
- database statement timeouts;
- workload queues;
- per-tenant export limits;
- rate limits on expensive reports;
- placement migration for sustained heavy tenants.
A schema boundary does not reserve CPU or I/O. A database-per-tenant design can still share the same underlying cluster.
Measure resource use before promising independent scalability.
Open Session in View
Disable Open Session in View:
spring:
jpa:
open-in-view: false
Long-lived persistence sessions complicate tenant context lifetime and can cause lazy database access after the intended service transaction has ended.
Map entities to response DTOs inside the transaction instead.
Testing Tenant Isolation
Isolation tests should attempt to break the boundary.
ORM query
- create the same external key in tenants A and B;
- query while A is active;
- verify only A's row is visible;
- repeat for B.
Primary-key lookup
- obtain B's UUID;
- request it while A is active;
- verify not found rather than forbidden data disclosure.
Insert ownership
- persist an entity while A is active;
- verify Hibernate populates
tenant_id=A; - attempt to force
tenant_id=B; - verify the write fails.
Cross-tenant foreign key
- create a project for B;
- try to create an A task referencing B's project;
- verify the composite foreign key rejects it.
Native SQL
- run every native repository query under tenant A;
- verify RLS prevents B's rows from appearing;
- test bulk update and delete statements.
Missing context
- call a repository without tenant context;
- verify it fails rather than using a default tenant.
Connection reuse
- perform an A transaction;
- return the connection to the pool;
- perform a B transaction;
- verify no tenant setting leaks between them.
Cache
- use the same resource ID for A and B;
- verify cache keys and results remain separate.
Kafka
- process an A event and then a B event on the same consumer thread;
- verify context is cleared and rebound.
Async work
- submit work to another executor;
- verify tenant context is passed explicitly or the task fails safely.
RLS role configuration
Run tests using the same privileges as the production application role. A superuser test connection can bypass RLS and produce misleading results.
Use PostgreSQL Testcontainers for these tests. H2 does not reproduce PostgreSQL RLS, schema, role, and session-setting behavior.
Troubleshooting
Every request uses the same tenant
Check:
- filter ordering after authentication;
- principal-to-tenant mapping;
- stale
ThreadLocalvalues; - missing
finallyor scoped cleanup; - a resolver that returns a default tenant;
- persistence sessions reused across contexts.
Hibernate filters ORM queries but native SQL leaks rows
@TenantId does not rewrite native SQL. Bind the PostgreSQL RLS tenant, include explicit predicates, and audit native queries.
RLS appears disabled
Check:
ENABLE ROW LEVEL SECURITY;FORCE ROW LEVEL SECURITY;- whether the application role owns the table;
- superuser or
BYPASSRLSprivileges; - whether
app.current_tenantwas set in the transaction; - policy
USINGandWITH CHECKexpressions.
Useful verification:
SELECT
current_user,
current_setting(
'app.current_tenant',
true
) AS tenant,
row_security_active(
'projects'
) AS rls_active;
A pooled connection uses the previous schema
Check connection reset behavior. Prefer transaction-local settings or driver-supported schema switching. Never depend on a connection's previous state.
Data inserts with a null tenant
Check:
@TenantId;- resolver registration;
- tenant context before transaction creation;
- native inserts bypassing Hibernate;
- background jobs using repositories directly.
A cache returns another tenant's value
Inspect the complete cache key. Tenant ID must be part of every tenant-owned key and invalidation message.
Async tasks lose tenant context
ThreadLocal does not automatically move to an unrelated executor task. Pass TenantId explicitly and open a scoped context inside the task, or use a context-propagation mechanism that is tested for that execution model.
Schema migrations differ between tenants
Maintain migration status per schema, stop routing to failed tenants, and resume from recorded state. Do not hide failures and continue as though every schema is current.
Decision Checklist
Before production, answer:
- What is the isolation topology?
- Where does tenant identity come from?
- How is membership verified?
- Does missing context fail closed?
- Does every tenant table contain the ownership key?
- Do unique constraints include tenant scope?
- Do foreign keys prevent cross-tenant references?
- Does Hibernate use
@TenantIdor an equivalent strategy? - Are native SQL and bulk operations protected?
- Is PostgreSQL RLS enabled and tested?
- Is the application role unable to bypass RLS?
- Is tenant state transaction-local?
- Are cache and lock keys tenant-scoped?
- Are Kafka consumers rebinding and clearing context?
- Are admin operations separated and audited?
- Are migrations and provisioning restartable?
- Can one tenant be exported, restored, suspended, and deleted?
- Have deliberate cross-tenant attack tests passed?
Conclusion
Secure multi-tenancy requires the tenant boundary to appear in every layer that owns or moves data.
For a shared-table Spring Boot and PostgreSQL system:
- derive tenant identity from authenticated claims;
- verify membership before opening tenant context;
- use a stable tenant identifier;
- fail when context is missing;
- use Hibernate
@TenantIdfor ORM-managed access; - bind PostgreSQL row-level security inside each transaction;
- include tenant ownership in unique and foreign-key constraints;
- protect native SQL, caches, Kafka, jobs, and exports;
- separate administrative access;
- use versioned migrations and explicit lifecycle workflows;
- test cross-tenant leakage with production-like PostgreSQL roles.
Multi-tenancy is successful when an application bug, replayed message, reused connection, or support action still cannot silently cross the tenant boundary.