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.