Skip to main content
ASoc
Comparison

Firebase vs. Postgres: Six RLS Policies a Client Can Never Reach

Grep-verified: zero client components query a table directly, so this schema's six row-level-security policies defend a path the browser never uses.

The ASoc Team8 min read

Firestore secures data with rules evaluated on every client read; Postgres secures it with row-level security evaluated by the database. Most comparisons stop at "document versus relational" and leave it there. This storefront's own schema makes a sharper point: zero of its client-side code ever queries a table directly, so its six row-level-security policies are defending a read path that no browser request can reach in the first place — a security posture Firestore's client-first model can't produce even in principle.

The short answer

Firestore is a NoSQL document database with real-time listeners and a security-rules language evaluated per document, per client request. Postgres is relational, and — as this app runs it, through Supabase — is queried only from server code, with row-level security as a second layer behind a boundary the client never crosses. Pick Firestore when the client needs to read data directly and update live (chat, presence, collaborative editing). Pick a server-fetched Postgres model when you can afford to route every read through your own backend and would rather not reason about per-document security rules at all.

The comparison that matters

FirestorePostgres via Supabase (this app)
Data shapeCollections of schema-flexible JSON documentsRelational tables, schema enforced by migrations
Who queries the databaseThe client SDK, directly, from the browserNothing — every read happens in a Server Component or Server Action; the browser never holds a database credential
Security enforcementFirestore Security Rules, evaluated per document on every client read6 row-level-security policies, evaluated by Postgres — but only reachable if something queries the table, and nothing client-side does
Real-time updatesonSnapshot() listeners, built inNone — pages are prerendered or re-fetched on navigation; no live subscription exists in this app
Complex queriesLimited joins; denormalization is the standard fixFull SQL — a real join across orders, entitlement_slots and profiles is one query
Where "auth check" livesInside the security rule itself, evaluated per requestIn auth.uid() inside the policy, and only exercised by server code that already knows who's asking

The "who queries the database" row is the one this repo can speak to with a grep, not an opinion.

Six policies, and a query surface that never reaches them

This schema's row-level security is standard shape — six for select policies, one per table, comparing the authenticated user's ID (the tables themselves are walked through in what a database schema actually is):

-- supabase/migrations/0001_commerce_init.sql
create policy "own profile"   on public.profiles          for select using ((select auth.uid()) = id);
create policy "own orders"    on public.orders            for select using ((select auth.uid()) = user_id);
create policy "own slots"     on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events   for select using ((select auth.uid()) = user_id);

-- supabase/migrations/0008_refund_requests.sql
create policy "own deliveries"      on public.download_deliveries for select using ((select auth.uid()) = user_id);
create policy "own refund requests" on public.refund_requests    for select using ((select auth.uid()) = user_id);

On a Firestore-shaped app, six rules like these would be the entire server-side security story — the client SDK queries orders directly, and the rule is the only thing standing between a signed-in user and someone else's row. Here, that is not what's happening. Grep this repo's two client components that import a Supabase client at all:

src/components/organisms/Header.tsx:  .then((supabase) => {
src/components/organisms/Header.tsx:    void supabase.auth.getUser().then(({ data }) => {
src/components/organisms/Header.tsx:    } = supabase.auth.onAuthStateChange((_event, session) => {
src/components/molecules/BuyButton.tsx:  const supabase = await loadSupabaseClient();
src/components/molecules/BuyButton.tsx:  } = await supabase.auth.getUser();

Every call is .auth.getUser() or .auth.onAuthStateChange() — identity only. There is no .from("orders"), no .from("profiles"), nowhere in src/components. Every actual table read in this app happens in a Server Component or a Server Action, using src/lib/supabase/server.ts, on a connection the browser never touches. The six policies above are real and correctly written, but in this app's current shape they are defense-in-depth for a query path that does not exist on the client — not the load-bearing security boundary a Firestore rule would be.

Why that split exists: three clients, one job each

src/lib/supabase/client.ts      — browser client, identity only (getUser, onAuthStateChange)
src/lib/supabase/server.ts      — server client, used by every actual data read
src/lib/supabase/admin.ts       — service-role client, bypasses RLS for audited writes
src/lib/supabase/lazyClient.ts  — dynamic import so the browser client's ~68 KiB doesn't ship to pages with no account UI

There is a billing consequence to that split as well, since Firestore charges per document read and a client-first app makes far more of them — the same "which meter is running" question Firebase Hosting cost works through for static files, where storage turns out to be free at this scale and transfer is the line that bills.

A Firestore app cannot make this split even if it wanted to: the whole point of the client SDK is that the browser is the primary query path, so the security rule has to do the job a server boundary does here. That is a real advantage when the client genuinely needs to read live — a chat thread, a presence indicator, a collaborative document — because there is no server round-trip to write. It is not an advantage for a storefront whose reads are "does this user own this order," which server code can answer once and hand down as already-scoped JSON.

Where Firestore actually wins the argument

To be specific about what "real-time, built in" buys you: onSnapshot() pushes a diff to every subscribed client the moment a document changes, with no extra infrastructure. Reproducing that on this stack means adding a pub/sub layer or polling — Postgres has no client-push primitive of its own. If this app needed a live order-status ticker that updates without a refresh, Firestore's model would cost nothing extra and this one would cost a WebSocket layer. It doesn't need that today; every page here is either prerendered or re-fetched on navigation, and the checkout flow resolves synchronously through a webhook rather than a live subscription. That's a decision this app made, not a limitation of Postgres — Supabase ships its own Realtime feature on top of Postgres's logical replication, at the cost of running something closer to Firestore's model again.

Mistakes and troubleshooting

SymptomCauseFix
A Firestore Security Rule silently denies a read that "should" workRules evaluate against the request's auth context and the document's own fields — a missing request.auth.uid check or a stale field reference fails closedTest rules with the Firestore emulator's rule-unit-testing library, not by re-reading the app code
RLS policy written, data still exposedA server client using the service-role key bypasses RLS entirely, by designKeep service-role usage in one server-only-guarded module and audit every call site
auth.uid() returns null in a Postgres policyNo verified JWT reached the request — usually a session-refresh step was skippedConfirm the auth proxy or session-refresh middleware ran before the query
Denormalizing a Firestore collection to avoid a join, then the two copies driftFirestore has no cross-document transaction that spans collections by defaultUse a Cloud Function trigger to keep denormalized copies in sync, or reach for Postgres if joins are frequent
Assuming RLS alone proves a table is safeA policy that is never queried against isn't exercised, and a policy with a subtly wrong using clause can pass review unnoticedGrep for every .from("<table>") call site and confirm which ones are client vs. server before trusting the policy list as documentation

Frequently asked questions

Is Firestore or Postgres faster? Neither wins outright — they're optimized for different shapes. Firestore is fast at single-document reads and real-time fan-out; Postgres is fast at relational queries (joins, aggregates, filters across tables) that Firestore would need denormalization or multiple round-trips to answer.

Can Postgres do real-time updates like Firestore? Yes, through an add-on rather than natively — Supabase Realtime listens to Postgres's logical replication stream and pushes changes to subscribed clients, which is the closest equivalent to onSnapshot(). Plain Postgres has no built-in push mechanism.

Does row-level security replace application-level authorization? Only for reads that actually go through it. As this schema shows, a correctly written RLS policy provides no protection if nothing ever queries that table with a client-scoped connection — the security boundary is wherever the query actually originates, not wherever the policy is defined.

If I'm already using Postgres, is there any reason to add Firestore too? The main reason is real-time client subscriptions without building your own push layer — chat, live cursors, presence. For a storefront-shaped app with server-rendered reads and no live-collaboration surface, adding Firestore would mean maintaining two databases for a feature this app doesn't have.

Does moving from Firestore to Postgres mean giving up the client SDK entirely? It means changing what the client SDK is for. This app's browser-side Supabase client (src/lib/supabase/client.ts, loaded lazily through lazyClient.ts) still exists — it just never calls .from(). Its only job is identity: getUser() and onAuthStateChange() for the header's signed-in state and the buy button's ownership check. A Firestore migration in the other direction would need to replace every onSnapshot() call with either a server fetch on navigation or a bespoke subscription layer, since Postgres gives you neither for free.

Templates in this post

ASoc Reach markets an AI-driven marketing agency, leading with a performance-analytics hero, an achievements band, and an eight-item AI services grid. ASoc Realm is a property-management platform site built around an occupancy-metrics hero, with per-sector use cases and a 50+ integrations grid. ASoc Relay markets a team-messaging platform with a live chat-widget hero, outcome stats, and five messaging solution blocks.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison11 min read

Gumroad Alternative: The Six Jobs You Take Back In-House

A hosted storefront does six jobs. Another one changes the fee; owning your storefront takes them back as code — 1,607 lines here, file by file.

Read more