Skip to content
SaaS Development6 June 2026 · 7 min readUpdated 30 September 2026

Firestore Rules vs Postgres RLS for Multi-Tenant SaaS

Firestore security rules vs Postgres RLS for multi-tenant SaaS: where each enforces the tenant boundary, what the docs say about limits and bypasses, and how Callidus used Firestore rules.

Firestore Rules vs Postgres RLS for Multi-Tenant SaaS

You are choosing where a multi-tenant SaaS enforces "clinic A must never read clinic B's records", and the two realistic answers are Firestore security rules or Postgres row-level security. Pick the stack your auth and data model already live in, then write down the one query your product runs most often and check whether that stack can enforce the tenant rule on it without a join.

People treat Firestore rules and Postgres RLS as rival religions. They are the same idea with different blast radii: push the tenant boundary into the data layer so a bug in your handler cannot leak one clinic's records into another. I checked the documentation behind every limit below on 30 September 2026.

How do Firestore security rules and Postgres RLS differ for multi-tenant SaaS?

Both enforce the tenant boundary inside the database layer; Firestore attaches rules to document paths and reads claims from the auth token, while Postgres attaches policies to tables and evaluates them for every row.

Firestore security rulesPostgres RLS
Where the rule livesOn a match path such as tenants/{tenantId}/...A policy on a table
DefaultAnything not explicitly allowed is deniedTable with RLS on and no policy is default-deny
Tenant contextCustom claims in the auth token (1,000-byte limit)JWT claim via auth.jwt(), or a session setting
Cross-document checksget()/exists(), limited to 10 calls per request (20 for multi-document operations), billed as readsAny SQL, including subqueries and joins
Who bypasses itServer client libraries and the Admin SDKSuperusers, BYPASSRLS roles, table owners unless forced
Main performance leverKeep checks on claims and pathIndex the tenant column, keep policies to one comparison

The Firestore rows come from the Firebase rules conditions page, the custom claims guide and the security rules overview. The Postgres rows come from the PostgreSQL row security documentation.

Both systems also have a door around the rules, and it is the door your own backend uses. Firebase states that server client libraries bypass all Firestore security rules and authenticate through Google Application Default Credentials instead, so a Cloud Function using the Admin SDK can read every tenant. Postgres has the equivalent in BYPASSRLS roles and in table owners, who skip row security unless you run ALTER TABLE ... FORCE ROW LEVEL SECURITY. In both worlds the safest habit is the same: backend code that acts for one tenant should take the tenant id from the verified token, never from a request body, and code that needs the bypass should live apart from it.

Test the boundary like an attacker would. Write one test per collection or table that signs in as a user from tenant A and tries to read, update and delete a record from tenant B, and expect every attempt to fail. Run them against a local or test instance, using a non-owner Postgres role so the bypass does not hide failures. This is the cheapest way to catch a rule that looks right but is scoped wrongly.

What does a Firestore tenant rule look like?

Tenant data sits under one path, and a single rule covers everything below it. This is an illustrative rule, not a copy of any production file:

match /tenants/{tenantId}/{document=**} {
  allow read: if request.auth.token.tenantId == tenantId;
  allow write: if request.auth.token.tenantId == tenantId
               && request.auth.token.role in ['owner', 'admin', 'manager'];
}

Risograph-style illustration of two symmetric geometric gate forms in hot pink and cobalt with halftone textures guarding clean data lanes, single cyan accent on the dividing line

The tenant id is part of the path, so the comparison needs no lookup. Firebase also documents that rules are not filters: if a query could return documents the rules deny, the whole request fails instead of returning a partial list. Your client queries have to be scoped to the tenant path.

What does a Postgres tenant policy look like?

The equivalent lives on each table. With Supabase, the claim comes from the token:

CREATE POLICY tenant_isolation ON patients
  FOR ALL TO authenticated
  USING (tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid);

CREATE INDEX patients_tenant_id_idx ON patients (tenant_id);

One policy with only USING also applies as the WITH CHECK clause, so the same comparison governs inserts and updates, as the Postgres documentation explains.

Which option is harder to get wrong at scale?

Postgres gives you more rope. Firestore's rule language is narrower, which removes some mistakes, though it does not remove the performance cost of cross-document lookups.

My unpopular take is that Firestore rules did not make me a better architect, they removed the slow option. Most Postgres RLS performance disasters are just the subquery the platform was happy to let you write. I should qualify that with what the documentation says. Firestore rules do allow get() and exists() lookups into other documents. They are capped at 10 calls per single-document or query request and 20 for multi-document operations, and the Firebase docs state you are billed for those reads even when the rules reject the request. So Firestore does let you write a membership lookup. It makes the cost visible and puts a ceiling on it, which is a different thing from making it impossible.

The dangerous Postgres pattern looks like this:

CREATE POLICY tenant_isolation ON patients
  USING (
    tenant_id IN (
      SELECT tenant_id FROM tenant_memberships WHERE user_id = auth.uid()
    )
  );

The Postgres documentation says the policy expression is evaluated for each row before conditions from the user's query, and that consulting other tables in a policy can create race conditions that allow information leakage if you are not careful. Supabase's RLS performance benchmarks show what this costs in their tests: one query fell from 171 ms to under 0.1 ms after an index, and another from 178,000 ms to 12 ms after wrapping auth functions in select. Those are test results on Supabase's data, so measure your own tables.

How does Callidus handle tenant isolation?

Here is what the project does, in my words:

On Callidus I did tenant isolation with Firestore rules, not Postgres RLS: tenants/{tenantId}/... paths, JWT-scoped role claims across six roles, and roughly twenty webhook events. The hard rule was that mutations during the billing grace period are blocked at both the route layer and the Firestore rules layer — belt and braces. If I had to rebuild it on Supabase tomorrow I would push tenant_id onto every row and keep each policy to one indexed equality check. I never hit the subquery-in-RLS trap, but not because Firebase is smarter — Firestore rules simply do not let you write the slow version.

The Callidus case study adds detail: the six roles are super admin, owner, admin, manager, practitioner and receptionist, the JWT carries tenantId and role, access helpers replace inline role checks, and the Firestore rules have 100% coverage. Callidus is React, Firebase and Stripe Connect. It does not use Supabase or Postgres, so nothing in this post claims a Postgres result from it. The longer Firebase write-up is Firestore rules for a multi-tenant clinic SaaS.

The cost of the claims approach is freshness. Custom claims travel inside the ID token, so the Firebase guide says new claims reach a client the next time a token is issued: at sign-in, when the token expires (typically after an hour), or when the client forces a refresh with getIdToken(true). The same guide limits the claims payload to 1,000 bytes and says claims are for access control, not profile data. That is why a double check, route layer plus rules, is useful when billing state changes: the route can read current state while the token catches up.

How do you keep Postgres RLS fast and safe?

Keep every policy to one indexed comparison, read tenant context from a claim users cannot edit, and keep the privileged key out of request handlers.

  1. Index the tenant column as the leading column of the indexes your queries use. Supabase's guidance says an unindexed filter column turns a read into a sequential scan.
  2. Wrap auth functions in select and name the role with TO authenticated, so the planner caches the call and skips anonymous users.
  3. Use app_metadata, not user_metadata. Supabase warns that policies relying on user_metadata can create security issues because users can modify it.
  4. Do not depend on session-level settings. In Supabase's transaction pooler mode, session-level state, including set and reset, is lost between transactions, and the PgBouncer feature page lists SET/RESET among the session features transaction pooling breaks.
  5. Keep the service key in backend jobs only. Supabase says a secret key "bypasses every Row Level Security policy you have", and the Postgres docs say roles with BYPASSRLS do the same. Admin code and request handlers should be separate modules with separate clients.

Should you choose Firebase or Supabase for a multi-tenant SaaS?

Choose the stack your auth and data model already fit, then enforce the tenant rule in that stack's data layer. Both work; both fail when application-layer checks replace them.

If your data is shallow and tenant-scoped, with few cross-entity queries, Firestore rules give you isolation that follows your document paths. If you need relational queries, reporting and joins, Supabase with RLS is the natural fit, and my React and Supabase RLS stack notes show the setup. For the tenancy model that sits above both options, see multi-tenant SaaS architecture and the glossary entry on multi-tenant SaaS.

Mixing them is possible: Supabase supports Firebase Auth as a third-party provider. It requires a role: 'authenticated' custom claim on every Firebase user, and the docs warn that for self-hosted Supabase, Firebase's shared signing keys mean tokens from an unrelated Firebase project could access your data unless restrictive RLS policies check the issuer and audience. That is a real extra moving part, so use it only with a reason.

What is the query you run most often: a single-tenant path read, or a join across entities? Your answer points at the stack, and that query is the first thing to test against your tenant rule.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do Firestore security rules work for multi-tenant SaaS?

Firestore security rules enforce tenant isolation by attaching conditions to document paths such as tenants/{tenantId}/..., usually comparing a tenantId custom claim in the auth token to the path. Anything not explicitly allowed is denied, and rules are not filters, so a query that could return forbidden documents fails entirely. Rules can look up other documents with get() and exists(), but only 10 calls per request (20 for multi-document operations), and those reads are billed. Server client libraries bypass the rules, so backend code must take the tenant from a verified token.

Is Postgres RLS good for multi-tenant SaaS?

Postgres RLS works well for multi-tenant SaaS when each policy is one indexed comparison on a tenant_id column. A table with RLS enabled and no policy is default-deny, and the policy expression is evaluated for each row, so subqueries and joins inside policies are where performance and leakage problems start. Superusers, BYPASSRLS roles and table owners bypass it, and so does Supabase's service key. PlanetScale's 2026 write-up prefers application-layer enforcement, so treat RLS as a backstop rather than your only control, and test it with a non-owner role.

How do I set up Supabase RLS for multi-tenant data?

Put a tenant_id column on every table, enable RLS, index that column and write one policy per table that compares it to a claim from the JWT. Use app_metadata for the claim, because Supabase warns that user_metadata can be modified by users, and wrap auth functions in a select so the planner caches the result. Name the role with TO authenticated. Avoid session settings, since Supabase's transaction pooler loses session-level state between transactions, and keep the service key in backend jobs only.

How do custom claims affect Firestore tenant isolation?

Custom claims carry the tenant id and role inside the Firebase ID token, which makes rule checks cheap but means changes arrive late. Firebase says new claims reach a client the next time a token is issued: at sign-in, when the token expires (typically after an hour), or after a forced getIdToken(true) refresh. The claims payload is limited to 1,000 bytes and is meant for access control only. When state such as billing status changes, keep a server-side check alongside the rule so stale tokens do not decide access on their own.