Skip to content

Supabase Row Level Security: The Multi-Tenant Pattern We Use Across FH Clients

One Postgres database, many tenants, zero data leakage. Here’s the RLS setup that holds up under real production traffic.

John Cravey with AIFounder6 min readUpdated Jul 6, 2026

Every FH client site shares the same Supabase project. BHR, CabCarpentry, Tivey, James Marina, Best Barns, fh-site itself — one Postgres, one Storage layer, one Auth instance. The isolation is enforced by Row Level Security policies that scope every read and write to a `site_id`. The service-role key never leaves a server context. The setup costs one project subscription instead of ten, the migration story is one place instead of ten, and the multi-tenant boundary is enforced at the database layer where it can’t be bypassed by a leaky API.

This is the posture we’ve been running for 18 months across the client book. Here’s how it actually works.

Free estimate · 2 minutes

Read the playbook. Now see it for your business.

The posts are the mechanism. The estimate below sketches the version we'd actually ship for your business, at your scale. About a minute, no opt-in.

Why one project, not one per client

Cost: Supabase pricing is per-project. Ten clients = ten subscriptions = $300+/month for projects that mostly sit idle. One shared project on the $25/month Pro tier covers all of them comfortably for the storage volume and request rate we run.

Operational simplicity: one place to manage migrations, one place to monitor logs, one place to set up backups. When we ship a schema change, it lands once and applies to every tenant at the same time.

Trade-off: a Supabase outage takes every client down. We mitigate with Cloudflare caching in front of every read endpoint so static content keeps serving even if Supabase is unavailable, and we run weekly backup verification.

The schema: site_id on every row

Every tenant-scoped table has a `site_id text not null` column. The value is a slug like `"bhr"`, `"cab"`, `"fh"`. There’s a `sites` table with the canonical list. Foreign keys enforce that every row points at a real site.

create table sites (
  id text primary key,
  name text not null,
  created_at timestamptz default now()
);

create table submissions (
  id uuid primary key default gen_random_uuid(),
  site_id text references sites(id) not null,
  name text not null,
  email text not null,
  phone text,
  message text,
  created_at timestamptz default now()
);

create index on submissions(site_id, created_at desc);

The RLS policies: scope by site_id, reject everything else

Every tenant-scoped table has RLS enabled. The policies allow reads and writes only when the caller’s JWT contains a `site_id` claim matching the row. The default is deny. There is no anon-read policy on tenant tables — anon access to client data is impossible from the client SDK.

alter table submissions enable row level security;

create policy "insert own site" on submissions
  for insert with check (site_id = (auth.jwt() ->> 'site_id'));

create policy "select own site" on submissions
  for select using (site_id = (auth.jwt() ->> 'site_id'));

Where the site_id comes from

Two paths. For authenticated users, the `site_id` is injected into the JWT at sign-in by a Supabase auth hook. For anonymous users (a public lead form), we use the service-role key on the server side — the role bypasses RLS but our server code explicitly sets `site_id` from the per-tenant environment variable. The client never sees the service-role key.

Storage buckets: one per tenant

Tenant data goes through RLS-scoped tables. Tenant media goes through bucket-per-tenant Storage. Each client has a public bucket (`fh-images`, `cab-images`, `tivey-images`, `bhr-images`) for marketing imagery, and a private bucket for anything sensitive. Public buckets are read-only-to-anon, write-only-to-service-role. Private buckets have RLS policies that scope to the user’s `site_id` claim.

The `imageUrl()` helper resolves bucket paths into public URLs. Swapping buckets later is a single-file change.

Migration workflow

We use the Supabase CLI for migrations, version-controlled in the org-defaults repo. Each migration is a numbered SQL file under `supabase/migrations/`. Local development runs against a local Supabase stack via `supabase start`; migrations apply locally first, get tested in staging, then ship to prod with `supabase db push`.

# Create a new migration
supabase migration new add_lead_score_column

# Edit supabase/migrations/202605061200_add_lead_score_column.sql
# Apply locally
supabase db reset

# Apply to prod
supabase db push --linked

The four migration rules we live by

  1. Migrations are forward-only. We do not write down-migrations. If a migration is wrong, we ship a new migration that fixes it.
  2. Never run a migration that takes more than 30s on a populated table without a maintenance window. ALTER TABLE … ADD COLUMN with a default value on a 5M-row table will lock the table.
  3. Always test on a clone of production data. `supabase db dump` then restore locally before migrating in prod. We caught a 10-minute LOCK on a James Marina migration this way.
  4. Migrations and code changes ship together. A migration that adds a column gets the code that uses it in the same PR.

Auth: per-tenant claims

Supabase Auth uses GoTrue with custom JWT claims. We inject `site_id` into the JWT via an auth hook that runs server-side on sign-in. The hook reads the user’s row in a `user_sites` join table and adds the relevant `site_id` to the claims. Every downstream RLS check uses `auth.jwt() ->> 'site_id'`.

For anon users (public site visitors filling out a form), there’s no JWT and the public RLS policies are deliberately empty. Public form submissions go through a server action using the service-role key, which sets `site_id` from the per-tenant env var. Never trust the client to provide its own site_id.

Backups and disaster recovery

Supabase Pro includes daily point-in-time recovery. We supplement with weekly `pg_dump` snapshots stored in a separate S3 bucket owned by FH (not Supabase) — the “three places, two formats, one offsite” rule. If Supabase ever fully fails, we can rehydrate the database into a fresh project from those snapshots.

Monitoring and alerting

Supabase exposes query performance and connection metrics in the dashboard. We also pipe slow-query logs to Better Stack for alerting on anything over 1s. The Postgres-level monitoring catches things the application monitoring doesn’t — a missing index, a runaway sequential scan, a connection pool exhaustion.

When NOT to use shared multi-tenancy

  • Regulated data: HIPAA, PCI, anything with strict data isolation requirements. Even with RLS, the regulator may insist on physical isolation. Spin up a separate project.
  • Tenants with wildly different load. One client doing 10k RPS will affect the database for everyone else. Pull the high-volume tenant onto their own project.
  • Customer-controlled schemas. If the tenant gets to define columns, the shared-schema approach breaks down. Use a different per-tenant strategy (schema-per-tenant or separate projects).

How this maps to the FH client roster

Every site we ship writes to the shared Supabase. Lead submissions land in `fh.submissions` partitioned by `site_id`. Client team members access their site’s data through an admin UI that scopes by JWT claims. Storage buckets resolve through the shared `imageUrl()` helper. The whole thing costs $25/month plus storage overage. If you’re running multi-tenant SaaS on per-tenant databases or trying to reinvent this pattern on raw Postgres, book a consultation — there’s usually a simpler version of what you’re building.

Answers

Frequently asked questions

Why one project for many clients rather than one project each?

Operational surface. One project means one thing to monitor, back up, upgrade, and secure. A project per client multiplies every routine task by the client count and guarantees drift, because nobody upgrades thirty projects on the same day.

What does the schema need for multi-tenancy?

A tenant key on every row, without exception. One table missing it is one table with no isolation, and it will be the one someone queries from a shared helper. Uniqueness constraints become composite with the tenant key, which is the change people forget until two clients collide on a slug.

What should the RLS policies actually say?

Scope to the tenant and reject everything else. Deny by default, allow explicitly for the tenant in context. Policies that check only for an authenticated user provide no isolation at all, which is the common mistake because it passes every test written by the person who wrote it.

Where does the tenant identifier come from?

From the request context rather than from client input. A tenant chosen by the caller is not a boundary. In practice it comes from the session claim or from server-side resolution of the host, and the important property is that a browser cannot ask for someone else's data by changing a parameter.

Do service-role keys bypass RLS?

Yes, completely, which is why they never reach a browser and why server code using one must apply tenant scoping itself. Most real isolation failures are not policy bugs; they are a service-role client used in a path that assumed the policies were still doing the work.

How should storage be isolated?

A bucket per tenant, so a policy mistake exposes one client rather than the whole book. Path-prefix isolation inside one bucket works until someone writes a helper that takes a path from a parameter, and then the blast radius is everyone.

What are the migration rules for a shared project?

Apply the migration before the code that depends on it, never edit an applied migration, keep one migration per logical change, and reload the API schema cache after DDL. The first rule matters most: code deployed against a missing column returns empty results silently rather than erroring.

Why does a new table need RLS enabled explicitly?

Because creating a table does not turn it on, so a new table in a tenant-scoped schema is world-readable through the API until someone remembers. It is the single most common gap in a multi-tenant setup and it is invisible in code review.

How should auth carry tenancy?

As a claim on the session, so policies can read it without an extra lookup on every query. The claim has to be set server-side at issue time; anything the client can influence is a suggestion rather than a boundary.

What should be monitored?

Policy denials, slow queries, auth failures, and storage rate limits. Denials in particular are the honest signal: a sudden rise usually means either an attack or a broken deploy, and both are things you want to know about before a customer reports them.

When is shared multi-tenancy the wrong choice?

When a client contractually requires isolation, when data-residency rules differ per tenant, or when one tenant's load would harm the others. Those are real reasons for separate projects. Wanting them to feel separate is not, and it costs the operational simplicity that made the pattern worth having.

How do I test that isolation actually holds?

Write tests that attempt cross-tenant reads and assert they return nothing, running as a normal user rather than a service role. Tests that only assert the happy path prove the query works, not that the boundary does, and the boundary is the entire point.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
AI Overviews and Zero-Click Search: The 2026 SEO Reality
Older post
Cloudflare DNS and CDN: The Base Configuration for Every FH Client Site
Keep reading

More from the blog

Supabase·5 min

Supabase Edge Functions: When They’re Worth It and When They’re Not

Edge Functions are great for jobs that have to live outside your Next app. Not everything does. Here’s the decision framework.

Supabase·3 min

Reading Supabase Logs: The Five Queries That Catch 80% of Production Issues

The Supabase log explorer is underused. These five queries are the first place we look when something’s wrong.

Supabase·4 min

Migrating from Firebase to Supabase: The Real Cost and the Step-by-Step Plan

Firebase pricing scales worse than Supabase past a certain point. Here’s the migration plan that worked for one of our clients.