Skip to main content
ASoc
Comparison

Firebase vs Convex: Where Authorization Lives

Rules in a DSL or logic in functions — judged on a shipped schema, including the check-then-act race that proved declarative rules can't hold an invariant.

The ASoc Team12 min read

Firebase and Convex both hand you a database, auth and serverless functions, so the feature grids look interchangeable. They are not, and the axis that separates them is where authorization lives: Firebase puts it in a declarative rules file next to the data, Convex puts it inside the functions that touch the data. Everything else follows from that choice.

The short answer

Choose Firebase if you need breadth — mobile SDKs with offline-first sync, push messaging, crash reporting, hosting and auth from one vendor — and you can live with a schemaless store and a rules DSL. Choose Convex if you are building a TypeScript web app and want end-to-end types, a declared schema, and reactive queries that update themselves, and you do not need the mobile ecosystem.

If you want the comparison against a relational backend instead, we have Supabase vs Convex and Firebase vs Postgres.

The comparison

FirebaseConvex
Data modelFirestore documents, schemalessDocuments with a declared TypeScript schema
Where authorization livesA declarative rules file (firestore.rules)Inside query/mutation functions
Types across the wireGenerated client types, schema not enforced at writeInferred end to end from the schema
RealtimeSnapshot listeners you subscribe and unsubscribeReactive queries that re-run automatically
TransactionsExplicit transactions and batched writes, with limitsMutations are transactional by default
Server logicCloud Functions, separate deploy unitFunctions are the API surface
Self-hostNo (client SDKs are open source)Yes — backend open-sourced under a fair-source licence
MobileThe strongest story here: native SDKs, offline persistenceWeb-first; mobile via the JS client
MaturityYears of production use, huge answer corpusYounger: fewer tutorials, fewer third-party integrations
Vendor exitFirestore export, then a rewrite of rules and queriesSelf-host the backend, or rewrite the functions

Both vendors change pricing often enough that any number here would be stale; read their own pages, and model the dimension that actually scales for you — document reads for Firestore, function calls and bandwidth for Convex.

Why we can speak to the axis that matters

This storefront runs neither. It runs Postgres on Supabase, which is a relational third option — so take nothing here as a verdict on either product's dashboard.

What it does give us is the one thing a feature grid cannot: we have shipped both authorization styles in production, on the same schema, and we know which jobs each one is good for. Reads here are protected declaratively, by row-level security policies. Writes are protected by functions. That split was not a preference; it was forced by a bug. Firebase-versus-Convex is, underneath, a question about that split, and it has a concrete answer.

Declarative rules: excellent, and strictly limited

Here is the declarative half of this project's authorization — four of its six policies, from supabase/migrations/0001_commerce_init.sql (0008 added two more in the same shape):

alter table public.profiles          enable row level security;
alter table public.orders            enable row level security;
alter table public.entitlement_slots enable row level security;
alter table public.download_events   enable row level security;

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);

This is the same shape as a Firestore rule that compares request.auth.uid to a field on the document, and it has the same virtues. It is short enough to review in one sitting. It cannot be bypassed by forgetting a where clause in application code. It applies to every client, including one someone writes against your API next year.

Note what the four lines do not grant: they are for select only. No client role can insert, update or delete any of these tables. Everything that changes state goes through a function — which is the Convex posture, arrived at from the other direction.

One implementation detail worth stealing if you write Postgres policies: (select auth.uid()) is wrapped in a scalar subquery rather than called bare. That lets the planner evaluate it once per statement instead of once per row, which is the difference between a policy you can afford on a large table and one you cannot.

The bug that decides the argument

Declarative rules express who may touch this row. They cannot express how many times per hour, because that is a claim about rows that do not exist yet.

This codebase learned it concretely. Downloads were rate-limited by reading a count and then inserting an audit row — two statements. From the migration that fixed it, supabase/migrations/0007_atomic_download_rate_limit.sql:

Root cause (CWE-367 TOCTOU / OWASP A04): the download endpoint enforced the hourly limit with a non-atomic check-then-act — read downloads_in_last_hour(), then a SEPARATE insert of the audit row. Under concurrency, N requests could all read the same count, all pass the < limit check, and all insert, so a burst of parallel requests bypassed the cap.

No rules file fixes that. The check and the write have to become one operation:

-- Serialize concurrent downloads for THIS user so count+insert is atomic.
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));

select count(*)::int into recent
from public.download_events
where user_id = p_user_id
  and created_at > now() - interval '1 hour';

if recent >= p_limit then
  return -1;  -- over limit — record nothing
end if;

insert into public.download_events
  (user_id, product_slug, framework, version, ip, user_agent)
values
  (p_user_id, p_product_slug, p_framework, p_version, p_ip, p_user_agent);

return recent + 1;

Three things in that excerpt generalise to both products:

  1. The lock is taken before the read. Lock-then-read is the whole fix; read-then-lock preserves the race.
  2. It is keyed per user, so two different buyers never contend.
  3. It depends on the isolation level. The migration documents it: correctness relies on READ COMMITTED, because the select then takes a fresh snapshot after the previous holder committed. Under REPEATABLE READ the snapshot could freeze earlier and the second caller could read a stale count.

That third point is the one to carry into either vendor. Convex's model matches this shape directly — a mutation is a transactional function, so "count then conditionally insert" is one unit by construction, and you do not reach for an advisory lock to get there. Firebase can also do it, with a transaction or a counter document, but the enforcement has moved out of firestore.rules and into a Cloud Function, and now you must also stop clients writing that collection directly — or the rule and the function disagree and the function loses.

The honest summary of the axis: declarative rules are the better tool for ownership, and they are not a tool for invariants. Any product that offers only rules will push your invariants into functions eventually; Convex starts there, and Firebase gets there after an incident.

Schema: a file you write versus a shape you hope for

The other real divergence is whether the database knows what your data looks like.

Firestore does not, by design. Nothing rejects a document whose total_cents arrives as the string "4900". In exchange you get the fastest possible early iteration and no migration step. Convex takes the opposite trade: you declare the schema in TypeScript, the client types are inferred from it, and a write that does not match is rejected.

This project's equivalent is eight numbered SQL files, and their names are the argument for a declared schema:

0001_commerce_init.sql           0005_retain_anonymized_download_audit.sql
0002_display_name_allowlist.sql  0006_pricing_v2_slot_kinds.sql
0003_commerce_rpcs.sql           0007_atomic_download_rate_limit.sql
0004_lock_down_rate_limit_fn.sql 0008_refund_requests.sql

Four of those eight are security or correctness repairs to earlier ones, each a reviewable diff with a dated rationale. That is the real benefit of a declared schema and it is not type safety — it is that the history of what you believed about your data is written down. A schemaless store gives you no place to record that 0004 locked down a function 0001 left open. You find out from an audit instead.

Where this bites hardest is a field whose meaning changes. 0006_pricing_v2_slot_kinds.sql reshaped entitlement slot kinds after the pricing model changed. In Firestore that is a backfill script plus a period where both shapes are live and every reader must handle both; in Convex or Postgres it is a migration with a known before and after.

What neither of them sells you, and you may not need

Convex's headline is reactive queries; Firestore's is snapshot listeners. Both are genuinely good, and this codebase uses zero realtime — no subscription, no socket, which is also why its Content-Security-Policy has no wss: entry at all.

That is worth saying plainly because realtime is the feature most likely to decide a comparison for reasons that will not survive contact with the product. A storefront's data changes when a release ships, not when a visitor is looking at it. If your app is a catalogue, a marketing site, a dashboard over slow-moving data, or anything where a refresh is an acceptable answer, reactive queries are a feature you pay for in architecture and never cash in.

Where it genuinely decides things: collaborative editing, presence, live chat, a feed that must update without interaction, or a mobile client that has to work offline and reconcile later. That last one is Firebase's strongest remaining claim and Convex does not match it today.

Keep the rules out of the vendor

One pattern survives either choice, and it is the cheapest insurance in this whole comparison: keep your business rules in plain functions with no database access.

src/lib/entitlements.ts here has zero imports. It takes a list of slots and a download target and answers whether the download is allowed:

export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
  if (slot.status !== "active") return false;
  switch (slot.kind) {
    case "all_access":
      return true;
    case "all_templates":
      return target.framework !== "backend";
    case "template_single":
      return (
        slot.productSlug === target.productSlug &&
        target.framework !== "backend"
      );
  }
}

export function authorizeDownload(slots: Slot[], target: DownloadTarget): boolean {
  return slots.some((s) => slotCovers(s, target));
}

Because it touches nothing, it is unit-testable without a database, a server or a mock — and the licence rules that decide who may download what are tested as arithmetic. The vendor-specific code is reduced to fetching rows and calling this.

The reason that matters to a Firebase-versus-Convex decision: whichever you pick, this file does not change. Rules written in firestore.rules are not portable or testable the same way; logic buried in a Convex mutation is testable but tied to its runtime. Pull the decisions out, leave the plumbing in, and the vendor becomes a replaceable layer rather than the shape of your application.

Troubleshooting and migration notes

SituationWhat goes wrongWhat to do
Porting Firestore rules to ConvexThere is no rules file; the mapping is one guard per functionWrite an assertOwner(ctx, doc) helper and call it first in every function
Porting Convex functions to FirebaseMutations were transactional by default; Firestore writes are notWrap multi-document writes in a transaction or batch explicitly
A rate limit that passes tests and fails in productionCheck-then-act under concurrencyMake count and write one operation; lock before you read
Rules allow a write a function also validatesClients can bypass the functionDeny direct client writes to that collection; the function is the only path
Counts drift after a burstNon-atomic incrementsUse a transaction, or a server-side function keyed per user
Types pass, data is wrong shape in the storeFirestore does not enforce the schema at writeValidate at the boundary; types are not a runtime check
Query is fast in dev, slow with real dataMissing index — both products need themAdd the index; our download audit carries (user_id, created_at) for exactly this
An old field means something newNo recorded migrationWrite the migration as a file, even if the store does not require one

Frequently asked questions

Is Convex production-ready? It is being used in production, and it is younger than Firebase — the practical cost is a thinner corpus of answers when something breaks at 2am, and fewer third-party integrations. Weigh that against end-to-end types, which remove a class of bug entirely. If your team is small and TypeScript-fluent, the trade usually favours Convex; if you need an answer to every question to already exist, it favours Firebase.

Can I self-host either one? Convex open-sourced its backend, so yes, with real operational work. Firebase is not self-hostable — the client SDKs are open source, the service is not. If "we must be able to run this ourselves" is a hard requirement, the comparison is already decided.

Which is safer by default? Neither; they fail differently. Firebase's rules are easy to write and famously easy to leave permissive — a misconfigured ruleset is one of the most common data exposures in the category. Convex's functions cannot be left open in the same way, because there is no public table surface, but every function must remember its own authorization check, and a forgotten check is invisible until someone looks. Declarative rules fail open and visibly; function guards fail open and quietly.

Do I need realtime? Probably less than the marketing suggests. Ask whether a user would notice a stale screen before their next interaction. If not, you are paying architecture for a feature you will not use — this codebase has none and has never wanted it.

Templates in this post

ASoc Pip (a forex-trading site with a live-rates dashboard preview), ASoc Press (a blog, news and magazine template) and ASoc Quest (a dark-theme games storefront) are front ends with no backend assumptions baked in, so either stack above drops in behind them.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the relational side of the same decision, see Convex vs Prisma.

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