In B2B SaaS, the worst-case bug is not a 500 error or a deploy regression — it’s a tenant-isolation bug that lets one customer’s data appear in another customer’s session. Microsoft Teams shipped one of these in 2023 with their cross-tenant guest-access feature. Slack has shipped versions of this. The OWASP Top 10 has a category for it (broken access control, currently #1). Every multi-tenant application has the latent risk.

The standard mitigation is “every query filters on tenant_id, and every code review checks for it.” This works as long as every developer remembers, every query is well-scoped, every code path goes through the right ORM, and no edge case slips through. Empirically, that level of consistency is hard to maintain at production-engineering scale.

We adopted a different approach: tenant isolation enforced at the Postgres row level via RLS, with the application layer treated as untrusted relative to the isolation guarantee. Here’s what that looks like, why we chose it, and what it costs.

The RLS primitive

Postgres Row-Level Security is a feature that lets you define policies on tables that filter or restrict which rows a query can see. Policies are SQL expressions evaluated by the database, transparently to the application. A query that “SELECT * FROM messages” returns only the rows the policy allows.

The shape of our setup:

ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON messages
  USING (workspace_id = current_setting('app.current_workspace')::uuid);

The application sets app.current_workspace per request, in the same database connection that runs the request:

SET LOCAL app.current_workspace = '01HX9...';

SET LOCAL scopes the variable to the current transaction, so it doesn’t leak between requests on a pooled connection. Every query inside that transaction is implicitly filtered by the workspace; SELECT * FROM messages only returns the current workspace’s messages, regardless of what the application code thought it was asking.

This pattern extends to INSERT, UPDATE, and DELETE via separate WITH CHECK policies. An attempt to insert a message with the wrong workspace_id is rejected at the database; an attempt to update or delete a message that belongs to a different workspace returns “0 rows affected.”

Why this beats application-layer scoping

Three reasons.

It’s structural, not procedural. Application-layer tenant scoping is a code review checklist — “did you remember to filter on workspace_id?” Code reviews catch most of these. Some slip through. The slipped-through ones become incidents. RLS is structural — the database refuses to return cross-tenant rows whether the application asked nicely or not. The code review checklist still exists, but the consequences of forgetting are caught at the database, not in production.

It defends against a class of bugs that doesn’t show up in tests. A common application-layer scoping bug is “the developer wrote the query for one tenant, tested it, it worked. Six months later a different developer added a new method that called the same underlying ORM helper, didn’t know about the scoping requirement, and bypassed it.” The original tests still pass. The new method has the leak. RLS catches this because the new method goes through the same RLS policy.

It survives architectural drift. Application code evolves. ORMs change. New code paths get added. Caching layers come in and out. The Postgres schema and policies are far more stable than the application code that touches them. Putting the isolation guarantee at the most stable layer means it survives the most change.

What it costs

Two real costs.

Operational complexity. Every Postgres connection has to set the app.current_workspace variable correctly, every time, for the right scope. We do this in a Laravel middleware that fires on every authenticated request, before any controller runs. The middleware reads the workspace from the session/auth context and sets the variable inside a transaction. If the middleware doesn’t fire, queries default to “no workspace set” and policies block everything — fail-closed by design.

The flip side: testing. Every integration test has to set up the workspace context the same way the middleware does. We have a Pest test helper (actingInWorkspace($workspace)) that wraps the same logic. New developers writing tests sometimes forget to use it; the test fails with “RLS denied access,” which is the right error message but takes a second to recognize.

Slightly more complex queries when you genuinely need cross-tenant access. Internal admin tooling — for example, “show me message volume across all tenants for billing” — needs to be able to see all rows. We handle this with a separate database role (admin_role) that has BYPASSRLS set, used only by clearly-marked admin code paths. The discipline is “only the admin role can bypass; the application role can never bypass.” This is enforced at the Postgres permissions layer, so an SQL injection in the application would still be blocked from cross-tenant access.

The pattern across our schema

We apply RLS to every table that has a workspace_id column. That’s most of them: messages, channels, users (well, workspace_memberships, since users can belong to multiple workspaces), files, audit_events, etc. Each gets a policy that joins through the appropriate path to the current workspace.

For tables that are owned by a different scope — for example, direct_message_threads which are owned by a pair of users rather than a single workspace — the policy is more elaborate, but the pattern holds: define the access predicate as a SQL expression and enforce it at the database.

The hardest case is “messages in DM threads.” A user can read a DM thread message if they’re a participant in the thread — the predicate joins messages → threads → thread_participants and checks the participant’s user_id matches the current user. The policy looks something like:

CREATE POLICY dm_thread_access ON messages
  FOR SELECT
  USING (
    thread_id IS NULL  -- channel message; falls through to channel policy
    OR thread_id IN (
      SELECT id FROM dm_threads
      WHERE EXISTS (
        SELECT 1 FROM dm_thread_participants
        WHERE thread_id = dm_threads.id
        AND user_id = current_setting('app.current_user')::uuid
      )
    )
  );

This is more complex than the workspace-scoped policy, but it’s a one-time write and then enforced for everyone. The application code that lists DM messages doesn’t need to know any of this; it just queries and gets only the rows the policy allows.

Performance

The honest answer: there’s overhead, and it’s small for the policies we use, but it’s measurable. RLS adds a predicate to every query that touches a protected table. If the predicate is well-supported by indexes, the cost is in the noise (single-digit-percent query latency increase). If the predicate is poorly indexed — for example, a policy that joins through three tables without supporting indexes — the cost can be significant.

The mitigation is the same as any other Postgres performance concern: index the policy predicates the same way you’d index the WHERE clauses of your most-common queries. We have indexes on (workspace_id) on every protected table; we have a covering index on (thread_id, ...) for the DM-thread policy. EXPLAIN ANALYZE shows the policies hitting indexes, which is the operational requirement.

For tables with more than ~10M rows, RLS performance has to be tested specifically. We’re not yet at that scale; when we get there, the answer is “more granular policies and partition tables by workspace if needed.” Both are well-trodden Postgres patterns.

When RLS isn’t the right answer

RLS doesn’t fit every multi-tenancy model. It’s optimized for the case where:

  • Tenants are well-defined and stable (a workspace doesn’t morph into a different workspace).
  • Most queries are within a single tenant.
  • Cross-tenant access is rare and admin-shaped.

If your tenancy model is shape-shifting (B2B2B with arbitrary customer-of-customer access), or if cross-tenant queries are the common case (a marketplace where buyers and sellers see each other’s data with elaborate scoping rules), RLS will fight you. In those cases, the right answer is application-layer scoping done very carefully, with structured access-control libraries that can express the rule shape.

For our case — workspaces with stable membership, occasional admin access, mostly-within-tenant queries — RLS is the right primitive. It moves the most dangerous failure mode (cross-tenant data leak) from “developer forgot a WHERE clause” to “the database refused the query,” and that move is worth the operational cost.

What this means in our security model

This is what S1 in our security principles means in practice: tenant isolation is enforced at the database row, not the application layer. RLS is the primitive that delivers it. The Microsoft Teams cross-tenant guest-access incident in 2023 — and incidents like it — is fundamentally a tenant-isolation failure where the application layer was trusted to keep tenants apart. We don’t trust the application layer for that property. The database keeps tenants apart.

For technical buyers and procurement teams evaluating us, the claim “tenant isolation is enforced by Postgres RLS, not by application code” is not marketing copy. It’s an architectural commitment that’s testable: any query our application makes against a protected table can be exercised in psql with a deliberately wrong app.current_workspace, and the policy will block it. We do this in our security tests, in CI, on every commit.

That’s the kind of architectural decision that compounds over time. Every quarter we ship more features and write more queries; every quarter the RLS guarantee continues to hold; every quarter we ship more “we trust the database to do this right” architecture instead of “we trust every developer to remember the WHERE clause.” It’s boring. Boring is the goal.