Content Agents

Zero-Trust Multi-Tenancy: How We Use Supabase RLS and Custom JWTs to Secure 1,000+ Brands

By Team · July 30, 2026

Category: under-the-hood

Zero-Trust Multi-Tenancy: How We Use Supabase RLS and Custom JWTs to Secure 1,000+ Brands

How we use Supabase RLS, custom JWTs, and PostgreSQL row-level security to enforce multi-tenant isolation across 1,000+ brands - and the silent bug that cost us two weeks.

Key takeaways

  1. The problem At 1,000+ brands sharing one database, relying on application code to filter by tenant is a convention that any missed WHERE clause, new endpoint, or unfamiliar engineer can silently break.

  2. Core insight Moving tenant isolation into PostgreSQL RLS policies backed by a cryptographically signed JWT claim means the database enforces the boundary on every query, independent of what the application code does or forgets to do.

  3. Practical outcome A reader can adopt the pattern of stamping tenant_id into the JWT via an auth hook, writing RLS policies that verify that claim, and always decoding tenant_id from the token directly rather than from the session object - so isolation holds even as the codebase grows.

We spent two weeks chasing a bug that only appeared for users who had switched workspaces. Data was leaking across tenant boundaries - not on every request, not consistently, but often enough to be terrifying. The root cause turned out to be a single line: we were reading session.user.app_metadata.tenant_id instead of decoding it from the JWT. Those two things look the same. They are not the same. At 1,000+ brands on a single database, the difference is the gap between a data breach and an incident report that never gets written.

The Setup: Why tenant_id Columns Aren't Enough

The naive approach to multi-tenancy is seductive because it's simple. You add a tenant_id column to every table, you filter every query with .where({ tenant_id: currentTenant }), and you ship. For a small team with one or two customers, it works. The implicit contract is: trust the application to filter correctly, every time, in every new endpoint, forever.

That contract breaks under load. Not because the original developers were careless, but because scale multiplies surface area. At 1,000+ brands, you have dozens of tables, hundreds of query paths, and a codebase that new engineers touch without full context. One forgotten .where() clause doesn't announce itself. There's no error. The wrong data just... returns. The user sees it. Or worse, you don't find out until someone notices they can read another company's editorial calendar.

Application-level filtering is a convention, not a constraint. The database doesn't know or care whether your application remembered to filter. It returns what you ask for. At small scale, discipline is enough. At scale, discipline is a liability.

We needed the database itself to enforce tenant isolation - not as a backup to the application layer, but as the primary guarantee. The shift is from "trust the code" to "trust the database." That shift changes everything downstream.

The Architecture: RLS, Policies, and the JWT Stamp

PostgreSQL Row-Level Security (RLS) is the enforcement layer. When RLS is enabled on a table and a policy is defined, Postgres evaluates the policy predicate before returning any rows - before your application code sees the result, before any ORM processes it. There is no application-level path that bypasses an RLS policy. The database enforces it at the query execution level.

Supabase exposes RLS natively, and the policies can reference the authenticated JWT via auth.jwt(). That's the hook. If every request carries a JWT with a verified tenant_id claim, and every RLS policy checks that claim against the row's tenant_id, you get tenant isolation that is mathematically enforced - not socially enforced.

The flow is: user signs in, auth hook injects tenant_id into the JWT, every subsequent query carries that JWT as a Bearer token, RLS policy evaluates auth.jwt()->'app_metadata'->>'tenant_id' against the row before returning it. No application code participates in the isolation decision. The database makes that call unilaterally on every query.

How the JWT Stamp Works

Supabase supports a custom auth hook called hook_custom_access_token. It runs on every token issue and refresh. In our implementation, the hook queries workspace_members for the signing user, extracts their active tenant_id, and injects it into the JWT's app_metadata before the token is returned to the client.

The resulting JWT payload looks roughly like this:

{
  "sub": "user-uuid",
  "app_metadata": {
    "tenant_id": "brand-uuid",
    "is_platform_admin": false
  },
  "iat": 1700000000,
  "exp": 1700003600
}

The token is signed by Supabase's auth service. The client receives it as an opaque Bearer token. The client cannot modify the tenant_id claim without invalidating the signature. Every request to the database carries a cryptographically verified tenant identity - not one asserted by the application, but one stamped and signed by the auth layer.

On the client side, reading the current tenant_id correctly means decoding it from the token itself, not from the session object:

// src/lib/jwtAppMetadata.ts
export function decodeJwtAppMeta(accessToken: string | undefined): Record<string, unknown> {
  if (!accessToken) return {};
  try {
    const payload = accessToken.split('.')[1];
    const decoded = JSON.parse(atob(payload)) as Record<string, unknown>;
    return (decoded.app_metadata ?? {}) as Record<string, unknown>;
  } catch {
    return {};
  }
}

// Usage in useAuth.ts:
const jwtAppMeta = decodeJwtAppMeta(session.access_token);
const tenantId: string | null = (jwtAppMeta.tenant_id as string) ?? null;
const isPlatformAdmin: boolean = jwtAppMeta.is_platform_admin === true;

The JWT payload is base64url-encoded. Decoding it on the client doesn't require verification - that happens server-side. But it gives you the authoritative, current value of tenant_id as stamped by the hook, not the possibly-stale value stored in the Auth database.

RLS Policies: The Enforcement Layer

Every sensitive table - articles, campaigns, settings, brand assets - carries an RLS policy in this shape:

CREATE POLICY "tenant_isolation" ON articles
  FOR ALL
  USING (
    tenant_id = (auth.jwt()->'app_metadata'->>'tenant_id')::uuid
  );

This policy is evaluated by Postgres before any rows are returned. It applies to SELECT, INSERT, UPDATE, and DELETE. A user cannot query rows that belong to a different tenant. A user cannot insert a row with a tenant_id that doesn't match their JWT claim. Even if a developer ships an endpoint with no explicit tenant filter - even if a third-party integration queries the table directly - the RLS policy holds.

That last point matters. The guarantee doesn't depend on every developer remembering to add a .where() clause. It doesn't depend on code review catching a missing filter. Postgres catches it. Every time. At the query level.

Security Definer RPCs: Audit and Elevation

Some platform operations legitimately need to cross tenant boundaries - suspending an account, impersonating a user for support, running a billing reconciliation. RLS would normally block these. The answer isn't to disable RLS; it's to use SECURITY DEFINER functions.

A SECURITY DEFINER function runs with the privileges of the function's definer, not the caller. That means it can bypass RLS. But we wrap every such function in a strict pattern: first, assert that the calling JWT has is_platform_admin: true. Second, perform the action. Third, write a tamper-evident log entry to platform_admin_audit with a hash of the payload.

An admin can do what they need to do. But every action is logged with a cryptographic record of who did it, when, and with what parameters. If something goes wrong, the audit table tells the story. The log entry is written inside the same transaction as the action - either both succeed or neither does.

What Went Wrong: The app_metadata Gotcha

Here's the bug we shipped. In early versions, we were reading tenant_id like this:

// DON'T DO THIS:
const tenantId = session.user.app_metadata?.tenant_id;
// This reads from the stored app_metadata, not the current JWT.
// It may be stale after workspace switches.

This worked most of the time. The session object and the JWT agree on tenant_id for users who sign in, load the app, and stay in one workspace. The failure appeared when a user switched workspaces mid-session. The auth hook stamps the new tenant_id into the refreshed JWT. But session.user.app_metadata is a cached representation of what's in the Auth database - not a live decode of the current token. In certain edge cases, those two values diverged.

The result: the client-side code thought it was operating in workspace A, the JWT said workspace B, and the RLS policy agreed with the JWT. Data that should have appeared didn't. Data that shouldn't have appeared did. The failure was silent - no error thrown, no console warning, just wrong data or missing data depending on the timing.

The fix was a single conceptual shift: trust the token, not the session. The JWT is signed and verified by the auth provider. The session object is a local copy that can drift. Always decode tenant_id from session.access_token directly using the decodeJwtAppMeta function above. The JWT payload is the authoritative source.

Two weeks of debugging traced back to one assumption about which copy of the data was canonical. It's the kind of bug that doesn't show up in unit tests because unit tests don't usually model mid-session workspace switches with token refresh timing. It shows up in production, with real users, when trust is already established.

Why This Matters: Zero-Trust at the Database Layer

Traditional multi-tenancy trusts the application. The database is a store; the application is the gatekeeper. That model works until the application has enough surface area that you can't audit every path. At 1,000+ tenants, you have enough surface area.

Moving isolation enforcement to the database flips the trust model. The database doesn't trust the application. It verifies every request against the JWT claim independently. This is what zero-trust means in practice at the data layer - not a marketing posture, but an architectural property. Every query is evaluated on its own credentials. No query inherits trust from the application context that issued it.

The practical benefit compounds over time. When you refactor the application, add new API endpoints, integrate third-party tools, or hire engineers who don't know the codebase's filtering conventions, the isolation guarantee doesn't change. You can add a new GET /articles endpoint with no WHERE clause and Postgres will still enforce the tenant boundary. The surface area grows; the attack surface doesn't.

That's the architecture we'd build again from day one. Not because it was easy to get right - the app_metadata bug cost us two weeks - but because the alternative is a codebase where tenant isolation is a convention that every engineer has to maintain manually, forever. Conventions drift. Database policies don't.

Key Takeaways

  • RLS policies run at the database layer - Postgres evaluates them before any application code sees the result, and no application path can bypass them.

  • Stamp tenant_id into the JWT via an auth hook, not into the session object. The token is signed and cryptographically verified; the session is a local cache that can drift.

  • Always decode tenant_id from session.access_token directly. session.user.app_metadata may be stale after workspace switches.

  • Use SECURITY DEFINER RPCs for operations that need to cross tenant boundaries - and wrap them in an audit log that writes in the same transaction as the action.

  • Zero-trust at the database layer means the isolation guarantee survives application refactors, new endpoints, and third-party integrations. Build the constraint into Postgres, not into your team's discipline.

Small wooden block with the word TRUST carved into it, surrounded by blue flowers.
Photo by Alex Shute on Unsplash
Small wooden block with the word TRUST printed on it, surrounded by blue flowers.
Photo by Alex Shute on Unsplash

Frequently Asked Questions

What is row-level security in a multi-tenant SaaS?

Row-level security (RLS) is a PostgreSQL feature that enforces access policies at the database level. In a multi-tenant SaaS, you define a policy that checks whether the requesting user's tenant ID matches the row's tenant ID before returning data. Unlike application-level filtering, RLS runs inside Postgres and cannot be bypassed by application code - a missing WHERE clause in your API doesn't create a data leak because the database enforces the boundary independently.

How does a custom JWT auth hook improve multi-tenant security?

A custom auth hook runs at token