DEV Community

Cover image for Supabase vs. MongoDB vs. Firebase: A Real App, Three Backends
Raphael Gab-Momoh
Raphael Gab-Momoh

Posted on Originally published at raphaelgmomoh.pages.dev

Supabase vs. MongoDB vs. Firebase: A Real App, Three Backends

Comparing Marketing Pages Is Not Comparing Databases

"Supabase vs. Firebase vs. MongoDB" comparisons are common and almost all of them compare feature lists, not running systems. This one builds the same small app — a notes tool with signup, create, and list — against all three, actually runs each one, and reports exactly what happened, including the parts that didn't go smoothly. The app is deliberately minimal so the comparison isolates what each backend does differently, not what the app happens to need.

Before Step 1, three terms this comparison leans on:

  • Row Level Security (RLS) — Postgres's own mechanism (which Supabase builds directly on top of) for enforcing per-row access control inside the database itself. A policy like "users can only see rows where user_id matches their own ID" is enforced by Postgres on every query, regardless of what client or API hits it.
  • Firestore Security Rules — a separate, non-SQL rules language that Firestore evaluates on every request, independent of the database engine underneath. Functionally similar goal to RLS, but a different language, different deployment mechanism, and — as this article found — a different operational model.
  • BaaS (Backend as a Service) — a category both Supabase and Firebase belong to: a hosted platform that provides authentication, a database, and an auto-generated or managed API, so an app team writes little or no backend code. MongoDB, by contrast, is just a database — everything above the data layer (auth, API, authorization) has to be built separately.

Step 1 — The app, minimal on purpose

One route to create a note, one to list them, gated behind authentication:

app.post("/notes", authMiddleware, async (req, res) => { /* create a note, owned by req.userId */ });
app.get("/notes", authMiddleware, async (req, res) => { /* list notes, scoped to req.userId */ });
Enter fullscreen mode Exit fullscreen mode

Why this specific feature set: create-and-list-scoped-to-a-user exercises exactly the place these three backends differ most — authentication, and per-row authorization. A feature set any smaller wouldn't touch authorization at all; anything larger would start comparing app complexity instead of backend complexity.


Step 2 — Supabase: self-hosted, zero backend code

supabase init
supabase start
Enter fullscreen mode Exit fullscreen mode

That second command pulled and started twelve containers — Postgres, GoTrue (auth), PostgREST (auto-generated REST API), Realtime, Kong (API gateway), Studio, and supporting services — on a single Standard_B2s Azure VM (3.8 GB RAM). All twelve came up healthy.

The entire "backend" for this app is one SQL migration:

create table if not exists notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id),
  title text not null,
  body text,
  created_at timestamptz default now()
);

alter table notes enable row level security;

create policy "Users can manage their own notes" on notes
  for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

No server, no route handlers, no auth middleware. PostgREST inspects this table and its RLS policy and immediately exposes a real, working REST API for it. Verified end to end against the real running stack:

curl -X POST http://localhost:54321/auth/v1/signup \
  -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
  -d '{"email":"test@notescompare.dev","password":"testpassword123"}'
# -> real JWT returned

curl -X POST http://localhost:54321/rest/v1/notes \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -H "Prefer: return=representation" \
  -d '{"title":"First real note","body":"via PostgREST","user_id":"'"$USER_ID"'"}'
# -> [{"id":"4cdcbee7-...","user_id":"ee5a831c-...","title":"First real note", ...}]
Enter fullscreen mode Exit fullscreen mode

Why this matters more than "Supabase has a nice dashboard": the RLS policy is enforced by Postgres itself, on every request, through an API that required writing zero lines of server code. The auth token, the row-scoping, and the REST endpoint all came from a fourteen-line SQL file.


Step 3 — MongoDB: same feature, real hand-written server required

MongoDB has no auto-generated API and no built-in per-document authorization. Everything Supabase gave for free had to be written:

function authMiddleware(req, res, next) {
  const token = (req.headers.authorization || "").replace("Bearer ", "");
  if (!token) return res.status(401).json({ error: "Missing token" });
  try {
    req.userId = jwt.verify(token, JWT_SECRET).sub;
    next();
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
}

app.post("/auth/signup", async (req, res) => {
  const { email, password } = req.body;
  const passwordHash = await bcrypt.hash(password, 10);
  const result = await users.insertOne({ email, passwordHash });
  const token = jwt.sign({ sub: result.insertedId.toString() }, JWT_SECRET, { expiresIn: "1h" });
  res.json({ access_token: token });
});
Enter fullscreen mode Exit fullscreen mode

Run against a real self-hosted mongo:8 container on the same VM:

curl -X POST localhost:3001/auth/signup -H 'Content-Type: application/json' \
  -d '{"email":"test3@mongocompare.dev","password":"testpassword123"}'
# -> {"access_token":"eyJhbGciOi..."}

curl -X POST localhost:3001/notes -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"title":"First mongo note","body":"via Express"}'
# -> {"userId":"6aab8be9...","title":"First mongo note", ...}
Enter fullscreen mode Exit fullscreen mode

It works, and it works well — MongoDB's own document model and query API are genuinely pleasant. But the honest comparison is the line count: Supabase's backend is fourteen lines of SQL; MongoDB's equivalent required a real password-hashing scheme, a JWT signing/verification layer, and manually filtering every query by userId in application code — and that authorization logic has to be correctly repeated on every single query by hand, with nothing at the database layer enforcing it if a future route forgets to filter.


Step 4 — Firebase: a real rejection, and a real tooling wall

Firebase Auth worked immediately — signup against the real Identity Toolkit REST API returned a genuine ID token with no setup beyond a project's public API key:

curl -X POST "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=$API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"test@notescompare.dev","password":"testpassword123","returnSecureToken":true}'
# -> real idToken returned
Enter fullscreen mode Exit fullscreen mode

Writing to Firestore with that token was correctly rejected:

{
  "error": {
    "code": 403,
    "message": "Missing or insufficient permissions.",
    "status": "PERMISSION_DENIED"
  }
}
Enter fullscreen mode Exit fullscreen mode

That rejection is Firestore's security rules doing exactly their job — the project's existing production rules didn't recognize a brand-new collection this test tried to write to, and Firestore defaults to denying anything a rule doesn't explicitly allow. The correct fix is deploying an updated rule scoping that collection to its owner, the same shape as the Supabase RLS policy above:

match /notes_compare_lab/{noteId} {
  allow read, delete: if request.auth != null && request.auth.uid == resource.data.userId;
  allow create: if request.auth != null && request.auth.uid == request.resource.data.userId;
}
Enter fullscreen mode Exit fullscreen mode

Deploying that rule non-interactively hit a real wall:

Error: Request to https://firebaserules.googleapis.com/v1/projects/eguari:test had HTTP Error: 403,
The caller does not have permission
Enter fullscreen mode Exit fullscreen mode

The Firebase CLI's stored login session lacked the scope needed to test and deploy security rules, and re-authenticating with broader scope requires an interactive browser login (firebase login --reauth), which explicitly refuses to run in a non-interactive session: "Cannot run login in non-interactive mode." A fresh brand-new Firebase project hit an even earlier version of the same wall — serviceusage.services.use permission denied, minutes after the project's own creation, on the account that had just created it.

This is the actual finding, not a workaround-and-move-on: Supabase and MongoDB were both fully reproducible from a script, self-hosted, with no dependency on an interactive human session. Firebase's tooling, at least for rules deployment and fresh-project setup, assumes a human is present to click through a browser at least once. That's a real operational difference between the three — not a knock on Firestore's security model itself, which behaved correctly by rejecting an unauthorized write.


Step 5 — What actually differs, stated plainly

Supabase (self-hosted) MongoDB (self-hosted) Firebase
Backend code needed for this app None (SQL only) ~70 lines (auth + API) None (client SDK only)
Authorization enforcement Postgres RLS, database-level Application code, must be repeated per query Firestore Security Rules, database-level
Self-hostable / portable Yes — full stack via supabase start Yes — official Docker image No — hosted only
Non-interactive automation Fully scriptable Fully scriptable Assumes an interactive human session for rules/project setup
Query model SQL (Postgres) Document (BSON) Document (protobuf-backed)
Realtime Built in (Postgres logical replication) Requires building it yourself (change streams) Built in

Closing Thoughts

The honest answer to "which is better" is that it depends on which row of that table matters most to a given team. If self-hosting and full automation matter — CI pipelines, ephemeral test environments, air-gapped deployments — Supabase and MongoDB are both genuinely reproducible from a script; Firebase, at least as tested here, is not. If minimizing backend code matters most, Supabase's RLS-enforced auto-API and Firebase's client-SDK model both beat MongoDB's "write it yourself" reality — but only Supabase's version of that convenience turned out to be scriptable end to end in this test. None of these conclusions came from reading documentation; they came from actually running all three and writing down exactly what happened, rejection messages included.

GitHub Repository: notes-compare-lab — all three implementations, the exact commands run, and the real error messages this article quotes.

Reviewed against Supabase CLI, MongoDB 8, and the Firebase CLI as of September 2026.

Supabase · MongoDB · Firebase · PostgreSQL · Firestore · Backend as a Service


Originally published on my portfolio.

Top comments (0)