This is the chapter to read if you read only one. Everything else in here costs you money or credibility. This one costs you the company.
The misunderstanding at the heart of it
Almost every vibe coder believes something like this: “Users log in, and then my app shows them their own data. So their data is protected.”
The load-bearing word is app, and it is doing work it cannot support. Your app is one client of your database. It is not a wall around it. When you use Firebase or Supabase from the browser, your frontend is talking to the database over the public internet, using a key that is (correctly, by design) published in your JavaScript.
Anyone can open a terminal and make the same requests your app makes, without your app. They do not have to load your page, click your buttons, or pass your login screen. Your React components are not in the conversation at all.
Firebase: the rules file
Firestore ships with a rules file, and every tutorial in existence starts you in test mode, which looks like this:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}Read it literally: for every document in the database, allow any read and any write, if true, which is always. No account needed. This is a public database with extra steps.
It is not that people decide to ship this. It is that it never complains. It makes every feature work, first try, all through development. There is no error, no warning, no red text. Firebase will email you about it, into an inbox you have stopped reading.
Here is what it should be:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Deny everything by default. Every rule below is a deliberate exception.
match /{document=**} {
allow read, write: if false;
}
// A note belongs to one person. Only they may read or change it.
match /notes/{noteId} {
allow read: if request.auth != null
&& request.auth.uid == resource.data.ownerId;
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.ownerId;
allow update, delete: if request.auth != null
&& request.auth.uid == resource.data.ownerId;
}
// Users may edit their own profile, but never their own privileges.
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow update: if request.auth != null
&& request.auth.uid == userId
&& !request.resource.data.diff(resource.data)
.affectedKeys()
.hasAny(['role', 'credits', 'plan', 'isPro']);
}
}
}Default deny, then open only what you mean to. Note the last rule: the user can edit their own profile, but NOT the fields that decide what they are allowed to do. That is failure #5 from the atlas, closed.
That affectedKeys().hasAny([...]) clause is the one people never write, and it is the one that stops a user from making themselves an administrator with a single line in the browser console. Roles and credits get written by your server, using the Admin SDK, which bypasses rules. Or they do not get written at all.
Supabase: Row Level Security
Supabase is Postgres, so its protection is RLS: policies attached to each table, evaluated on every query, no matter who is asking or how.
The catastrophic default here is different from Firebase’s, and in some ways nastier. If you create a table through the SQL editor and never enable RLS, the table has no policies at all, and a table with no policies is fully readable and writable by anyone holding the anon key, which is published in your JavaScript. There is no permissive rule to find and delete. There is simply nothing there.
create table notes (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
body text not null,
created_at timestamptz not null default now()
);
-- Without this line the table is public. This is THE line.
alter table notes enable row level security;
create policy "read own notes"
on notes for select
using (auth.uid() = owner_id);
create policy "insert own notes"
on notes for insert
with check (auth.uid() = owner_id);
create policy "update own notes"
on notes for update
using (auth.uid() = owner_id)
with check (auth.uid() = owner_id);
create policy "delete own notes"
on notes for delete
using (auth.uid() = owner_id);Enabling RLS with no policies denies everything: a safe, loud default. You then add back exactly what you intend. auth.uid() is the verified user from the JWT; it cannot be forged by the client.
Find your unprotected tables right now
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
-- rowsecurity = false -> fully exposed. Fix immediately.Every row this returns is a table the internet can read. Run it before you launch, and again after every migration, because a new table created by an AI agent will not have RLS on unless somebody said so.
The service role key deserves its own warning
Supabase gives you a service_role key that ignores every policy you just wrote. It exists so your server can do administrative work. It is, functionally, the master key to the entire database.
The model will sometimes reach for it when RLS blocks something during development, because it makes the error go away, instantly, and restores the working app. That is the most dangerous fix in this entire manual. If that key ever appears in a client component or a NEXT_PUBLIC_ variable, your policies are decoration and your database is open.
See API keys and env vars for how to check whether it is in your bundle. It takes ninety seconds and you should do it today.
Where permission checks actually go
The rule generalises past the database, so hold onto it: a check that runs where the user can see it is a check the user can skip.
| Check | In the UI | On the server / in rules |
|---|---|---|
| Hiding the Admin button | Good UX. Zero security. | The thing that actually stops them. |
| “Only owners can delete” | Prevents an accident. | Prevents an attack. |
| Form validation | Helpful error messages. | The only validation that counts. |
| “Pro users only” | Shows the upgrade prompt. | The reason they have to pay. |
Both columns are worth having. Just be extremely clear with yourself about which one is load-bearing, because when the model writes only the left column, the app looks and behaves exactly as if it had written both.
Test it the way an attacker would
Not by clicking around your app while signed in. That tests the courtesy layer. Test the law: query the database directly, with no session at all, exactly as a stranger would.
// Supabase, signed out, no session, no app.
const res = await fetch(
"https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=*",
{ headers: { apikey: "YOUR_ANON_KEY" } }
);
console.log(await res.json());
// [] good. RLS is denying you.
// [ {...}, ... ] every row in that table is public. Fix it today.Use your real project URL and your real anon key. Both are public already; that is the point. If rows come back, your database is open to the internet, and it has been the whole time.
That five-line snippet is the single most valuable test in this manual, because it is the exact thing a stranger would run, and it answers the only question that matters with evidence rather than opinion. Run it against every table you have.