Published on
· Updated

Secure Multi-Tenancy with Spring Boot, Hibernate, and PostgreSQL

Authors

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 @TenantId for 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_path handling 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;
  • JdbcClient and JdbcTemplate;
  • 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 ThreadLocal values;
  • missing finally or 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 BYPASSRLS privileges;
  • whether app.current_tenant was set in the transaction;
  • policy USING and WITH CHECK expressions.

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 @TenantId or 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 @TenantId for 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.

Official References