Skip to main content
Back to blog
ArchitectureSecurityMulti-tenancy

Multi-tenant isolation: why we enforce it three times

Prabhix Engineering · 14 July 2025 · 8 min read

When we designed the Prabhix platform, we chose shared-schema multi-tenancy over schema-per-tenant. The reason is operational: a single organization may grow to 100,000 users, and per-schema designs make connection pooling, online migrations, and index management painful at that scale.

But shared schema means every query is a potential cross-tenant leak if you get lazy. We enforce isolation in three deliberately redundant layers:

**Layer 1 — JWT claims.** Every access token carries the active organization ID (`org`). The API rejects requests where the claimed org doesn't match the resource being accessed.

**Layer 2 — TenantFilter.** A servlet filter resolves the org claim into a request-scoped `TenantContext`. Before any business logic runs, the filter validates that the authenticated user is actually a member of that organization. A missing or invalid tenant context throws — it never silently defaults.

**Layer 3 — Hibernate filter.** All tenant-scoped entities extend `TenantScopedEntity`, which registers a Hibernate `@FilterDef` that appends `organization_id = :orgId` to every query automatically. Even if a developer forgets a WHERE clause, the ORM won't return another tenant's rows.

On top of this, we enable Postgres Row-Level Security on the highest-risk tables — `mail_messages`, `mail_threads`, and `billing_invoices` — as a defence-in-depth backstop.

The redundancy is intentional. Any single layer can fail during a refactor or a rushed feature. Three layers mean a bug has to survive three independent checks before it becomes a data breach.

**Practical takeaway:** if you're building multi-tenant SaaS, don't rely on convention ('we always add org_id to queries'). Encode isolation in infrastructure — filters, ORM hooks, and database policies — so correctness doesn't depend on every developer remembering every time.