SaaS Development6 June 2026 · 8 min read

Firestore Rules vs Postgres RLS for Multi-Tenant SaaS

A head-to-head on enforcing multi-tenant isolation in the data layer: Firestore security rules vs Postgres RLS, grounded in the Callidus production build.

Firestore Rules vs Postgres RLS for Multi-Tenant SaaS

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. The choice between them in any firestore rules vs postgres rls decision is not theological — it is about what failure mode you can tolerate and which stack you are already committed to.

I made this call building Callidus, a B2B SaaS for UK aesthetic clinics. Patient records. GDPR. Real regulators. I chose Firestore rules over Postgres RLS, and I would make the same choice again — with a tighter list of conditions than I had going in.

The Same Idea, Two Different Constraint Sets

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

Both systems push tenant isolation into the data layer. In Firestore, tenant context is structural: every document lives under tenants/{tenantId}/... and rules attach to that path. In Postgres with RLS, tenant context is declarative — a policy appended to every query via current_setting('app.tenant_id') or auth.jwt(). The tenant check is the same idea; the implementation surface is different.

The meaningful difference is what each system allows you to write. Firestore rules have a constrained expression language — no joins, no aggregations, no subqueries. A rule that checks tenant membership looks like this:

allow read: if request.auth.token.tenantId == resource.data.tenantId;

That is a single equality comparison. The rule cannot fetch a row from another collection to do the check. If you need more context, it has to be in the JWT claim already.

Postgres RLS has the full expressive power of SQL. You can write a policy that subqueries another table, a policy that does a recursive CTE, something that reads like a reasonable access check and performs terribly under production load.

That asymmetry is the whole argument.

Firestore Rules Did Not Make Me a Better Architect — They Removed the Slow Option

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.

The dangerous pattern in Postgres row-level security looks like this:

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

That subquery runs for every row evaluated. At 50,000 patient records across 200 tenants, the query planner may handle it. At 500,000 rows with uneven tenant distribution, you will see planning regressions that are hard to reproduce in dev because your dev database has 50 rows and you cannot tell the difference until a tenant starts onboarding at scale.

Firestore does not allow that policy to exist. The rule language is not expressive enough to write it. So you cannot ship the slow version by accident. You need to encode tenant context into the JWT upfront, which forces you to think about token freshness, role encoding, and claim shape before you write a single rule.

The constraint is the feature.

It does cost you something real. On Callidus I had six distinct roles: clinic owner, practitioner, receptionist, billing admin, support agent, audit-read. Getting six roles with meaningful permission differences encoded into a Firebase JWT via custom claims took two days of Admin SDK wiring. On Supabase with React + Supabase RLS, that role model would live in a tenant_memberships table and policies would join against it. More expressive, faster to write, more performance risk if you let the join creep into the policy.

Firestore Path Structure and Billing State Enforcement

For multi-tenant SaaS on Firestore, structural isolation uses top-level tenant documents as namespace roots:

tenants/{tenantId}/
  patients/{patientId}
  appointments/{appointmentId}
  invoices/{invoiceId}
  staff/{staffId}

Rules attach to the path, not to individual documents:

match /tenants/{tenantId}/{document=**} {
  allow read: if request.auth.token.tenantId == tenantId
              && request.auth.token.role in roles;
  allow write: if request.auth.token.tenantId == tenantId
               && !request.auth.token.billingGracePeriod
               && request.auth.token.role in writeRoles;
}

The billingGracePeriod flag in the JWT is worth explaining. When a clinic's subscription lapses and enters a grace period, I do not immediately revoke read access — they still need to see patient records. But I block mutations: no new appointments, no new invoices. The Firestore rule enforces this at the data layer regardless of what the frontend attempts. The Route Handler also checks before calling Firebase, but the rule is the backstop.

Thursday night, 11pm, a Stripe webhook misfired and the billing state for one clinic got stuck in grace period even after payment succeeded. The Route Handler was seeing the wrong state; the JWT had not refreshed after the webhook retry resolved. The Firestore rule was still blocking writes correctly because the JWT carried the stale billingGracePeriod: true flag. The patient could not book. That was the right outcome while I diagnosed the webhook timing bug — not a silent data corruption.

Belt and braces.

How the Callidus Firestore Rules Actually Work

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 webhook architecture matters here. Roughly twenty Stripe events — subscription created, updated, past due, invoice finalized, payment failed — each potentially update billing state in the JWT. Updating a custom claim requires an Admin SDK call that forces a token refresh on the next client request. That refresh window is why the double check exists: in the seconds between a webhook firing and a client refreshing its JWT, the old token is still active. The Route Handler catches the wrong state first. The rule catches anything that slips past.

Postgres RLS Done Right

For Supabase builds specifically, Postgres RLS works well when the policy discipline is strict. The pattern that holds under production load: one indexed equality check, no subqueries in the policy expression.

-- JWT-based, no session config required
CREATE POLICY tenant_isolation ON patients
  USING (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid);

-- The index that makes this fast
CREATE INDEX patients_tenant_id_idx ON patients (tenant_id);

Two Supabase-specific caveats.

First: PgBouncer in transaction mode — which is what Supabase's default connection pooler uses — does not persist SET LOCAL between statements outside an explicit transaction. If you are setting tenant context with set_config, you need to be inside a transaction, or use the auth.jwt() approach which reads from the JWT and does not rely on session state at all. The JWT approach is the right default on a new build.

Second: the service role key bypasses RLS entirely. Admin operations should live in isolated backend functions with zero code paths accepting user-supplied tenant context. The pattern that creates a leak: an admin function starts as a private utility, gets a tenant_id parameter added for convenience, and then gets called from a handler that trusts the frontend. Feels fine in isolation. Becomes an RLS bypass six months later when someone adds a feature.

Actually — let me be more precise. The service role bypass is not a flaw in RLS. It is correct behavior for admin operations. The flaw is structural: admin functions that need the bypass should never share a code path with request handlers that carry user context. Separate modules, separate Supabase clients, no exceptions.

Which Pattern Fits Your Build

The firestore rules vs postgres rls question resolves on two axes: existing stack commitment and data access complexity.

If you are on Firebase for auth and Firestore is the natural fit for your data model — shallow hierarchies, no cross-entity joins, no analytical queries — Firestore rules give you isolation that the language itself enforces. You pay upfront in JWT claim engineering and get a simpler mental model for every access check after that.

If you are building on Supabase with Next.js, use RLS with the JWT-based tenant context pattern and a strict no-subquery discipline on every policy. The performance issues are manageable with index hygiene. The Callidus case study runs on Firebase, but if that build started today on Supabase, the RLS approach would be the correct default.

The difficult case is mixing both: Firebase auth with Supabase for relational queries. Verifying Firebase JWTs in a Supabase context is possible — you configure Supabase to trust the Firebase issuer — but it adds a moving part that is rarely worth the complexity unless you have a hard reason for both stacks. Stack coherence has real ongoing value. Every new engineer on the team needs to understand the JWT chain before they can debug any B2B SaaS auth issue. Multiplying that by two providers is a real cost.

For new builds: pick one, go all the way into it, and enforce the tenant boundary at the data layer with whatever mechanism that stack provides. Both Firestore rules and Postgres RLS work. Both fail when you substitute application-layer checks and call it defense in depth.

What is the access pattern that is making you hesitate between the two — is it joins, or role complexity?

DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Senior full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do Firestore security rules enforce multi-tenant isolation?

Firestore security rules enforce multi-tenant isolation structurally, not through policies. Every document lives under a path like tenants/{tenantId}/... and rules attach to that path. A rule like `request.auth.token.tenantId == tenantId` is a single JWT equality check — the language does not allow joins or subqueries, so you cannot accidentally write a slow cross-tenant query. The tenant boundary is baked into the URL of every document. The tradeoff: any context needed for access decisions must already be in the JWT as a custom claim, which requires upfront Admin SDK wiring.

What are the common Postgres RLS pitfalls for multi-tenant SaaS?

The most dangerous pattern is a subquery in the policy: USING (tenant_id IN (SELECT tenant_id FROM tenant_memberships WHERE user_id = auth.uid())). That subquery runs for every row evaluated and creates planning regressions that are invisible in dev databases with 50 rows. The safe pattern is a single indexed equality check against a JWT claim: USING (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid). Two other common issues: PgBouncer transaction mode does not persist SET LOCAL between statements, so use auth.jwt() instead of set_config for tenant context; and the service role key bypasses RLS entirely, so admin functions must stay isolated from user-context code paths.

Should I use Firebase or Supabase for multi-tenant SaaS?

The decision primarily follows existing stack commitment. If you are already on Firebase for auth, Firestore path-scoped rules are a natural fit — the isolation is structural and the constraint language prevents slow policies by design. If you are building on Supabase with Next.js, Postgres RLS with JWT-based tenant context is the correct default — better tooling integration, cleaner migration paths, and manageable performance with good index hygiene. Mixing Firebase auth with Supabase for relational queries is possible but adds a JWT verification complexity that is rarely worth it unless you have strong reasons for both providers.

How do you implement tenant isolation in Firestore without RLS?

Firestore tenant isolation uses path-scoped rules rather than row-level policies. Structure all tenant data under a top-level path: tenants/{tenantId}/collection/document. Attach rules to that path with a rule like: allow read, write: if request.auth.token.tenantId == tenantId. Encode tenant membership and role information as custom claims in the Firebase JWT via the Admin SDK — this is the only way to do multi-role access since Firestore rules cannot query other collections to check membership. For billing state or grace-period enforcement, add a flag to the custom claim and refresh it on Stripe webhook events.

What is the performance difference between Firestore rules and Postgres RLS at scale?

Firestore rules perform predictably at scale because the language is constrained to single-document reads and JWT equality checks — there are no join operations that can degrade. Postgres RLS performance depends entirely on policy design. A single indexed equality check (tenant_id = auth.jwt()...) is fast and scales linearly. A subquery in the policy expression can degrade to a nested loop scan with uneven tenant distribution at high row counts. At 50,000 rows the difference may not be visible; at 500,000 rows with large tenants it often is. The safe RLS pattern is strict: one indexed equality check per policy, no exceptions.