scipio
Scope my job → hello@scipio.com.au

AI-built apps

Supabase Row Level Security, explained for founders

If your app runs on Supabase, Row Level Security is the thing standing between each user’s data and everyone else. You do not need to write it yourself, but you should understand what it does and how to check it.

Row Level Security (RLS) is a database feature that decides, row by row, which records each user is allowed to read or change. In Supabase, the browser talks to the database directly using a public key, so RLS is not an optional extra: it is the security model. With RLS switched on and sensible policies in place, a logged-in user can only see their own rows. With RLS off, or with a policy that allows everything, anyone holding the public key can read or change the whole table.

Our pre-launch checks for AI-built apps flag RLS as the most important thing to look at. This article goes one level deeper, so you can follow what a developer is telling you and test the result yourself.

Why does Supabase let the browser talk to the database?

In a traditional app, the browser talks to your server, and your server decides what to fetch from the database. Supabase removes that middle layer for most requests. Your front end uses the Supabase client library to query tables directly, which is why tools like Lovable and Bolt can produce working apps so quickly.

The trade-off is that the database itself has to enforce the rules your server used to enforce. Postgres, the database underneath Supabase, does this with RLS. Every query that arrives is checked against the policies on that table before any rows come back.

What are the anon key and the service_role key?

Supabase projects have two kinds of key, and mixing them up is one of the most common serious mistakes in AI-built apps.

  • The anon key (newer projects call it the publishable key, starting sb_publishable_) is meant to be public. It ships in your front-end code. On its own it gives only what your RLS policies allow, for logged-out visitors as the anon role and for logged-in users as the authenticated role.
  • The service_role key (newer projects call it the secret key, starting sb_secret_) bypasses RLS completely. It is for trusted server-side code only, such as Edge Functions, scheduled jobs or webhook handlers. If it is in the browser, your policies do not matter, because anyone can copy it and read everything.

Supabase has been moving projects from the older anon and service_role keys to the newer publishable and secret keys, so you may see either naming depending on when your project was set up. The rule is the same: the public one goes in the browser, the secret one never does. If a secret key has ever been in front-end code or a public repository, rotate it.

What is a policy, in plain English?

A policy is a rule attached to a table that says: for this action, a row is allowed if this condition is true. Policies are written in SQL, but they read fairly naturally. A typical one says “a user may select rows from projects where the row’s user_id equals the ID of the logged-in user”.

That last part is where auth.uid() comes in. It is a function Supabase provides that returns the ID of the user making the request, taken from their login token. A policy condition like user_id = auth.uid() means each user only ever matches their own rows. For a logged-out visitor, auth.uid() returns nothing, so the condition is never true and they see nothing.

Policies are set per action: select (read), insert (create), update (change) and delete. Two parts of a policy are worth knowing by name:

  • using decides which existing rows a user can see or act on. It applies to reads, updates and deletes.
  • with check decides what a new or changed row is allowed to look like. It applies to inserts and updates, and stops a user creating a row that claims to belong to somebody else.

A common gap in generated code is an insert policy without a proper with check, which lets a user write rows with another person’s user_id. Another is a policy that just says true, which allows everyone. AI tools sometimes add these to make an error go away.

What are the common RLS mistakes?

  • RLS is disabled on a table. Tables created through raw SQL or migrations do not always have RLS switched on automatically, so newer tables can be missed.
  • RLS is on but a policy allows everything. A policy using true for the anon or authenticated role is effectively no protection.
  • Read is protected but write is not. The select policy is correct, but update or delete has no condition, so a user can change other people’s rows.
  • Roles are trusted from the client. A policy checks an is_admin column that users can update themselves. Role checks should rely on data users cannot change.
  • Views and functions skip the rules. Database views and some functions can run with their owner’s permissions and return rows RLS would otherwise hide. They need checking separately.
  • Storage is forgotten. Files in Supabase Storage have their own policies. Uploaded invoices or ID documents can be exposed even when the tables are locked down.
  • Team access is bolted on. Once accounts have several members, user_id = auth.uid() is no longer enough, and policies need to check membership of a team or organisation.

How do you test RLS with two users?

Reading policies is useful, but testing is what proves them. You can do a basic version of this yourself:

  1. Create two test accounts, User A and User B, with different email addresses.
  2. As User A, create some records: a project, a booking, an uploaded file, whatever your app stores.
  3. Log in as User B and try to see A’s records. Try the normal screens, then try changing IDs in the URL.
  4. Try writes, not just reads. As B, try editing or deleting one of A’s records by its ID. A developer can do this quickly from the browser console using the public key.
  5. Try it logged out. Use the public key with no login and query each table. Anything that returns user data is a problem.
  6. Check the dashboard advisor. Supabase’s dashboard includes security checks that flag tables without RLS and some risky settings. Treat it as a useful prompt, not a full audit.
  7. Repeat after changes. Every new table or feature is a chance for a missing policy. Make the two-user test part of your release routine.

Do these tests on a staging copy or with test data where you can. If you find another real user’s data is exposed in production, fix the policy first, then think about whether you have obligations to tell anyone. Our article on the Privacy Act and your website forms covers the basics, and the OAIC’s guidance on the Notifiable Data Breaches scheme is the authority on when notification is required.

Should you fix RLS yourself?

If you are comfortable reading SQL, you can fix simple single-user policies with care. The risk is in the complicated cases: teams, admin roles, shared records, storage, and anything involving payments. Those are worth a second pair of eyes, because a policy that looks right can still leave a gap.

If you are deciding whether the app is worth fixing at all, our guide on whether to fix or rebuild a vibe-coded MVP covers that decision. In most cases, getting RLS right is part of a fix, not a reason to rebuild.

If you only do three things

Confirm RLS is enabled on every table that holds user data, make sure the secret or service_role key is nowhere near your front end, and run the two-user test before every release. Those three steps catch most of the serious data exposure problems we see in AI-built apps.

If you would like someone to review your policies and run the tests properly, our AI build rescue starts with a fixed-price diagnostic. If you would rather fix it yourself with a developer alongside, help with your build covers pairing sessions and code reviews. Paid diagnostic from $350. Most rescue fixes land between $500 and $4,000.

SAsk Scipio 2-min brief