Cloud-Native & DevOps · Sep 2026 · 18 min read
Supabase vs. MongoDB vs. Firebase: A Real App, Three Backends
The same minimal notes app — signup, create a note, list notes — built three times against three real backends. One needed zero backend code. One needed a hand-written auth server. One rejected a write with a real 403 and exposed a genuine tooling limitation along the way.
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_idmatches 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 */ });
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
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);
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", ...}]
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
In this self-hosted, vanilla MongoDB setup — no Atlas, no App Services — there's no auto-generated API and no built-in per-document authorization; both are Atlas-specific managed features this test deliberately didn't use, since the comparison point was self-hosting. Everything Supabase gave for free had to be written by hand here:
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 });
});
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", ...}
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
Writing to Firestore with that token was correctly rejected:
{
"error": {
"code": 403,
"message": "Missing or insufficient permissions.",
"status": "PERMISSION_DENIED"
}
}
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;
}
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
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