How Hibernate ORM applies tenant isolation when configured with the DISCRIMINATOR multi-tenancy approach. Working production code can be found in this repository: https://github.com/abstratium-dev/abstrauth.
Configuration
Set the kind of multi-tenancy to use n application.properties:
quarkus.hibernate-orm.multitenant=DISCRIMINATOR
An entity declares a tenant discriminator column using @TenantId:
@Entity
@Table(name = "T_oauth_clients")
public class OAuthClient {
@Id
@Column(length = 36)
private String id;
@TenantId
@Column(name = "org_id", length = 36)
private String orgId;
// other fields...
}
A TenantResolver provides the tenant ID for each request:
@PersistenceUnitExtension
@RequestScoped
public class JwtOrgResolver implements TenantResolver {
@Override
public String resolveTenantId() {
// E.g. extract orgId from the JWT Bearer token
}
}
What Hibernate Does Automatically
INSERT — org_id is auto-populated
Java:
em.persist(client);
Generated SQL:
insert into T_oauth_clients
(allowed_scopes, auto_subscribe, client_id, client_name, client_type,
created_at, org_id, redirect_uris, require_pkce, id)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
The org_id value is set automatically from the resolved tenant — you never set it in code.
em.find() — org_id filter added automatically (when hitting database)
Java:
em.clear(); // Clear persistence context to force DB round-trip
OAuthClient found = em.find(OAuthClient.class, id);
Generated SQL:
select oc1_0.id, oc1_0.allowed_scopes, oc1_0.auto_subscribe, oc1_0.client_id,
oc1_0.client_name, oc1_0.client_type, oc1_0.created_at, oc1_0.org_id,
oc1_0.redirect_uris, oc1_0.require_pkce
from T_oauth_clients oc1_0
where oc1_0.id = ?
and oc1_0.org_id = ?
Hibernate appends org_id = ? to the WHERE clause when em.find() hits the database.
Note: If the entity is already in the persistence context (1st level cache), no SQL is emitted—Hibernate returns the cached entity directly.
JPQL SELECT — org_id filter added automatically
Java:
em.createQuery("SELECT c FROM OAuthClient c WHERE c.id = :id", OAuthClient.class)
.setParameter("id", id)
.getResultList();
Generated SQL:
select oc1_0.id, oc1_0.allowed_scopes, oc1_0.auto_subscribe, oc1_0.client_id,
oc1_0.client_name, oc1_0.client_type, oc1_0.created_at, oc1_0.org_id,
oc1_0.redirect_uris, oc1_0.require_pkce
from T_oauth_clients oc1_0
where oc1_0.org_id = ?
and oc1_0.id = ?
Hibernate prepends org_id = ? to the WHERE clause.
JPQL UPDATE — org_id filter added automatically
Java:
em.createQuery("UPDATE OAuthClient c SET c.clientName = :name WHERE c.id = :id")
.setParameter("name", "New Name")
.setParameter("id", id)
.executeUpdate();
Generated SQL:
update T_oauth_clients oc1_0
set client_name = ?
where oc1_0.id = ?
and oc1_0.org_id = ?
JPQL DELETE — org_id filter added automatically
Java:
em.createQuery("DELETE FROM OAuthClient c WHERE c.id = :id")
.setParameter("id", id)
.executeUpdate();
Generated SQL:
delete from T_oauth_clients oc1_0
where oc1_0.id = ?
and oc1_0.org_id = ?
What Hibernate Does NOT Filter
Dirty-check UPDATE (modify entity + flush)
Java:
OAuthClient found = em.find(OAuthClient.class, id);
found.setClientName("Updated Name");
em.flush();
Generated SQL:
update T_oauth_clients
set allowed_scopes=?, auto_subscribe=?, client_id=?, client_name=?,
client_type=?, created_at=?, redirect_uris=?, require_pkce=?
where id=?
No org_id in the WHERE clause. Hibernate considers the entity already tenant-validated because it was loaded through a tenant-filtered operation.
em.remove (delete managed entity)
Java:
OAuthClient toDelete = em.find(OAuthClient.class, id);
em.remove(toDelete);
em.flush();
Generated SQL:
delete from T_oauth_clients
where id=?
No org_id in the WHERE clause. Same reasoning — the entity was already loaded in a tenant context.
em.find (when entity is already in persistence context)
Java:
em.persist(client); // entity now in persistence context
em.flush();
OAuthClient found = em.find(OAuthClient.class, id); // no SQL emitted
No SQL generated — Hibernate returns the entity from its first-level cache. The tenant filter is not applied because no database query occurs.
Security Implications

Safe in practice because:
- You can only obtain a managed entity through a tenant-filtered SELECT (
em.findfrom DB, JPQL) or by persisting it yourself - An attacker cannot inject a cross-tenant entity into the persistence context through normal JPA operations
Potential risk if you:
- Use
em.getReference(id)with an ID from user input without a prior tenant-filtered load - Manually set an entity’s ID from external input and call
em.merge() - Call
em.find()on an entity that was previously loaded into the persistence context by a different tenant (rare edge case)
Mitigation: Prefer JPQL for UPDATE and DELETE operations, which always include the tenant filter. Alternatively, always load entities via em.find or a JPQL query before mutating them.
Leave a Reply
You must be logged in to post a comment.