Supabase and Firebase: security rules you forgot to write

$ cat blog/supabase-firebase-row-level-security.md
✓ vibe coding
✓ AI-generated code
✓ Supabase
✓ Firebase
9 min read
$ 

Your AI tool built a working app in an afternoon. Users sign up, see their own notes, and add new ones. The browser calls the database directly, with no backend in between, and it all just works. That's the appeal of Supabase and Firebase, and it also hides the one thing they ask of you: you have to write the access rules yourself.

In a normal app, your server decides who can read what. In a backend-as-a-service app, the browser holds a key and talks straight to the database. The only thing standing between a visitor and your data is a set of rules that live in the database or in a config file. If those rules are missing or too loose, the app still works, which is exactly why nobody notices.

The public key is not the boundary

We covered API keys in frontend bundles in an earlier post. Supabase's anon key (and the newer publishable key that replaces it) and Firebase's web config are in that category by design. They identify your project, and they're meant to be shipped to every browser. Finding one in your JavaScript isn't a leak.

That only holds if something else does the authorizing. Anyone can open the browser's dev tools, copy the key and send the same requests your app sends, with their own parameters. So the real question is never "is the key hidden?" It's "what is a request carrying only this key, or this key plus some random user's login, allowed to do?" Supabase answers that with Postgres row-level security. Firebase answers it with security rules.

browser public key + JWT RLS policy auth.uid() = user_id database your rows only browser public key only RLS off open door database every row top: policy on, scoped to the user. bottom: table without RLS.
The public key only gets a request to the door. With RLS on and a policy, the database checks who is asking. With RLS off, any request carrying the key goes straight through.

Supabase: no RLS, no protection

Supabase exposes your Postgres tables through an auto-generated API. A request carries the public key and, if the user is logged in, their token. Postgres then applies row-level security (RLS): policies attached to a table that decide which rows a given user can select, insert, update or delete.

The catch is that RLS is something you turn on per table. The dashboard's Table Editor enables it for you, but a table created with SQL or a migration file (which is what most AI tools write) needs it enabled explicitly. Without it, a table the API exposes can be read and written by anyone holding the public key.

A typical AI-generated migration looks like this:

-- Works in the demo. Exposes every row to every visitor.
create table notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  body text not null
);

Nothing here is wrong as SQL. It's just missing the part that makes it safe:

create table notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null default auth.uid(),
  body text not null
);

alter table notes enable row level security;

-- Reads: only your own rows.
create policy "read own notes" on notes
  for select to authenticated
  using (auth.uid() = user_id);

-- Inserts: you can only create rows owned by you.
create policy "insert own notes" on notes
  for insert to authenticated
  with check (auth.uid() = user_id);

-- Updates: you can only touch your rows, and can't hand them to someone else.
create policy "update own notes" on notes
  for update to authenticated
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Deletes: same rule.
create policy "delete own notes" on notes
  for delete to authenticated
  using (auth.uid() = user_id);

The two clauses do different jobs. The using clause filters which existing rows a statement can see or touch. The with check clause validates the new values being written. For updates you usually want both, otherwise a user could edit their own row and change user_id to someone else's. Once RLS is on and there is no policy for an operation, that operation is denied, which is the safe default. The dangerous cases are the table with RLS off and the policy that says using (true).

Things to watch for

  • Policies written to make an error go away. When an insert fails with a permissions error, an AI tool may "fix" it with using (true) or a policy for the anon role. The error disappears and so does the protection.
  • The service_role key. It (and the newer secret key) bypasses RLS completely. It belongs in server-side code and in secrets, never in a NEXT_PUBLIC_ variable, a mobile app or a browser bundle. If it has ever been exposed, rotate it.
  • Trusting data the client sends. Don't take user_id from the request body and believe it. Derive it from auth.uid(), as the column default and the policies above do.
  • Sensitive columns. RLS works on rows. If a table mixes public fields with private ones (emails, roles, internal flags), put the private part in a separate table or behind a server-side function.

Supabase's dashboard also has a Security Advisor that flags tables in the public schema without RLS enabled. It's worth a look before every launch, though it checks the live project, not the code you're about to ship.

Firebase: rules are a separate file

Firebase has the same shape with different syntax. Firestore, the Realtime Database and Cloud Storage each have their own security rules, deployed separately from your app code. A Firestore database started in "test mode" gets rules that let everyone in until an expiry date:

// Firestore "test mode" style: anyone can read and write until the date
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2026, 11, 1);
    }
  }
}

That rule exists so a tutorial works on day one. It says nothing about who the user is. Realtime Database and Storage rules can be just as open (for example ".read": true, ".write": true or allow read, write: if true;). The expiry date makes it look temporary, but plenty of apps either extend the date or replace it with something equally open to get unstuck.

A locked-down version ties each document to the signed-in user:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Each user can only touch documents under their own id.
    match /users/{userId}/notes/{noteId} {
      allow read, write: if request.auth != null
                         && request.auth.uid == userId;
    }
    // Everything else: no access from client SDKs.
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Rules are an allow-list: if no rule matches, the request is denied, and the catch-all if false just makes that intent explicit. Two Firebase-specific points are worth knowing. Rules are not filters: a query has to be constrained to what the rules allow (for example, reading under the user's own path), or it's rejected as a whole. And the Admin SDK (like Firebase's other server client libraries) bypasses rules entirely, so it plays the same role as Supabase's service_role key and must stay out of client code.

Why AI tools leave these open

This isn't a mystery, and it isn't unique to AI tools. A model asked to "build a notes app" optimizes for an app that works. The quickest way to a working demo is a table with no policies or a rule that lets everything through, and a demo with a broken read doesn't look like a demo.

There's also a structural reason. Policies and rules live outside the code the tool is editing. They're in a SQL migration somebody never asked for, a firestore.rules file nobody opened, or a dashboard toggle. The app code can look perfectly clean, with a careful login flow and correct types, while the data layer underneath is wide open. We described this pattern more generally in why vibe-coded apps leak: the checks that matter are the ones nobody asked for.

You can fix that with a prompt, and it helps. Something like: "For every table you create, enable row level security and write select, insert, update and delete policies scoped to auth.uid(). Don't use service_role on the client. List any table that has no policy." For Firebase, ask for a firestore.rules and storage.rules file with a default-deny catch-all, plus emulator tests. Then verify the output rather than trusting it.

How to check your own app

Do this against your own project, ideally a staging copy with test accounts.

  1. List tables without RLS. In the Supabase SQL editor, query pg_tables for tables in the public schema where rowsecurity is false, and open the Security Advisor.
  2. List tables with RLS but no policies. These are safe but probably broken, which is the kind of thing that gets "fixed" with using (true) later.
  3. Look for always-true policies. Search your migrations for using (true) and with check (true), and ask for each one whether it's really meant to be public.
  4. Try to cross the line. Sign in as test user A, grab the token, and from a script or the browser console try to read, update and delete rows that belong to test user B, using your own project's API. Every attempt should come back empty or denied. Try again with no login at all.
  5. For Firebase, use the Rules Playground and the emulator. The Rules Playground in the Firebase console simulates a read or write, as a signed-in or unauthenticated user, against the rules in the editor. The Firebase Emulator Suite lets you run the same checks in automated tests, so a loosened rule fails CI instead of production.
  6. Grep the client for secrets. Search your bundle and source for the service_role key and Admin SDK credentials. Neither belongs there.
  7. Treat expiring rules as a to-do. If your rules still contain a test-mode date, replace them today.

The next post in this series covers the general pattern, broken access control and IDOR: checking that each user can only reach their own objects in your own API code.

How OpenRouting helps

OpenRouting reviews your code, not your Supabase or Firebase project. It doesn't connect to your dashboard or inspect live databases, so it can't tell you what's enabled in production today. What it can do is read the files you commit: SQL migrations, rules files and the application code around them. The access control agent checks that sensitive actions are authorized for the specific object being touched, which is the same question RLS and security rules answer, and the secrets and data exposure agent looks for hardcoded keys, including a service key committed to the repo.

Every finding gets a verdict, a plain-English explanation and a suggested fix, and pull request review runs on the files a change touches, so a new migration or rules file gets looked at before it merges. Pair that with the checks above, because the live project is something only you can inspect.

Questions

Is it safe to put the Supabase anon key in frontend code?

Yes, the anon key (and the newer publishable key) is meant to ship to every browser and identifies your project. It is only safe when row-level security is enabled and your policies are correct, because anyone can copy it from dev tools and send their own requests.

Is row-level security enabled by default in Supabase?

The dashboard's Table Editor enables it for you, but a table created with SQL or a migration file needs it enabled explicitly. Without it, a table the API exposes can be read and written by anyone holding the public key.

What is the difference between using and with check in a Supabase RLS policy?

The using clause filters which existing rows a statement can see or touch, while with check validates the new values being written. For updates you usually want both, otherwise a user could edit their own row and change user_id to someone else's.

Are Firestore test mode rules safe to ship?

No. Test mode rules let anyone read and write until an expiry date and say nothing about who the user is. Replace them with rules that tie each document to request.auth.uid and a default-deny catch-all.

Keep reading