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:


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 appNone (SQL only)~70 lines (auth + API)None (client SDK only)
Authorization enforcementPostgres RLS, database-levelApplication code, must be repeated per queryFirestore Security Rules, database-level
Self-hostable / portableYes — full stack via supabase startYes — official Docker imageNo — hosted only
Non-interactive automationFully scriptableFully scriptableAssumes an interactive human session for rules/project setup
Query modelSQL (Postgres)Document (BSON)Document (protobuf-backed)
RealtimeBuilt 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