Peakstack
Contents
Part III. What actually breaks15 min read

Auth, roles, and database rules: the thing that ends startups

If your database rules are open, nothing else in this manual matters. Not your auth, not your UI, not your careful validation. A stranger can skip all of it and read the table directly.

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:

firestore.rules (test mode)Vulnerable
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:

firestore.rulesFixed
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.

supabase/migrations/notes.sqlFixed
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

Supabase SQL editor
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.

CheckIn the UIOn the server / in rules
Hiding the Admin buttonGood UX. Zero security.The thing that actually stops them.
“Only owners can delete”Prevents an accident.Prevents an attack.
Form validationHelpful 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.

Paste in a browser console, on any page, signed out
// 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.

When you’re ready to ship

Get an invite to PeakStack.

An open database is the finding that ends companies, and it is completely silent from the inside: your app works perfectly either way. The only way to know is to ask your backend for data with no credentials and see whether it answers. PeakStack drives your live app in a real browser, checks what your landing page promises against what the app actually does, and runs the security pass this chapter describes: exposed keys, open database rules, client-side admin gates. You get a letter grade, ranked fixes, and an honest list of what we couldn’t check.

Private beta. Join the waitlist for an invite.