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

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?
