Disclosure: AI agents wrote this article and no lawyer or professional security tester reviewed it. It is general information and a checklist, not a guarantee that your app is secure. We sell a paid check related to this topic (linked once, at the end).
You built an app with Lovable, Bolt, v0 or Cursor, with Supabase behind it, and it works. Before real users sign up, there is one class of problem worth an hour of your time: who can read or change rows in your database. Scanners and AI reviewers catch many of these, but they can miss a policy that exists and still lets everyone in. This checklist lets you verify it yourself.
Why this matters in Supabase
Supabase exposes your database tables in the public schema through an automatic API. Your app’s front end talks to that API with the “anon” key, which is public by design: anyone can read it in the browser. What stops a stranger from using that key to dump a table is Row Level Security (RLS). If RLS is off on a table, anyone holding the anon key can usually read and write it. If RLS is on with no policies, nobody can. Everything in between depends on the policies you wrote, or the ones your AI tool wrote for you.
Check 1: Is RLS on for every table? (5 minutes)
Open the Supabase dashboard, go to the Table Editor, and look at each table in the public schema. Tables without RLS show a warning. Or run this in the SQL Editor:
select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
Every row should say true in the rowsecurity column. For any false, either turn RLS on or confirm the table really is meant to be fully public. Also open the Security Advisor (under Advisors in the dashboard); it flags missing RLS and several related mistakes.
Check 2: Read your policies for “let everyone in” (10 minutes)
List every policy:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Look for these patterns:
using (true)orwith check (true)on tables holding private data. RLS is “on”, but the rule allows every row to everyone it applies to.- Policies that apply to the
anonrole (signed-out visitors) when only signed-in users should see the data. - Update policies with no
with check. A user may be allowed to update their own row, then change the owner column to someone else’s ID, or change aroleoris_admincolumn on their own row. - Rules based on user-editable metadata. The user metadata that a signed-in user can change from the client should not decide who is an admin. Store roles in a table the user cannot write to.
- Tables with a missing command. A table may have a select policy but nothing for delete, which is fine, or an insert policy that lets users insert rows owned by anybody.
The normal healthy pattern for per-user data looks like this:
create policy "own rows: read" on public.notes
for select to authenticated
using ( (select auth.uid()) = user_id );
create policy "own rows: write" on public.notes
for insert to authenticated
with check ( (select auth.uid()) = user_id );
Check 3: Test with two accounts (15 minutes)
Reading policies is not enough; test the behaviour. Create two test users, A and B, and add some data as A. Then, signed in as B (or with no session at all):
- Open your app’s pages that list data. Do you see A’s records?
- In the browser, copy a URL that contains one of A’s record IDs and open it as B.
- In the browser dev tools, find a request your app sends to
/rest/v1/your_tableand replay it as B with?select=*. You should get only B’s rows, or none. - Try to edit or delete one of A’s records as B. It should fail.
- Try to change your own role or admin flag as B. It should fail.
Run these only against your own project and your own test accounts.
Check 4: Keys in the browser (5 minutes)
The anon (publishable) key may be in your front-end code. The service role key must never be. It bypasses RLS completely. Search your repository and your deployed JavaScript bundle for service_role and for long strings starting with eyJ. Also check any variable name that starts with VITE_ or NEXT_PUBLIC_: those values are shipped to every visitor. If the service key ever appeared in a public place, rotate it in the dashboard and treat the data as exposed.
Check 5: Storage buckets and views (10 minutes)
- Storage. In the Storage section, check which buckets are public. A public bucket serves any file in it to anyone who has the URL. Invoices, ID scans and uploads from users should be in private buckets with policies that match files to their owner.
- Views. A view can return data from a table protected by RLS and still show it to everyone, because by default views run with their owner’s rights. On recent Postgres versions you can create the view with
security_invoker = trueso it respects the caller’s RLS. Check any view in thepublicschema. - Functions. Database functions marked
security definerrun with elevated rights. If they are callable from the API, make sure they check who is calling.
Check 6: Sign-in settings (5 minutes)
- Is email confirmation required before an account can sign in? If not, anyone can sign up with someone else’s address.
- Is the list of allowed redirect URLs limited to your real domains, with no leftover wildcard or test addresses?
- Is open sign-up what you want? For an internal tool, turn it off.
Check 7: Your own API routes and functions (10 minutes)
RLS protects the database API only. If your app has its own server routes or edge functions, each one that returns or changes data must verify the caller itself. Call each route with no token. If it answers with data, fix it. Also check that no route accepts an owner ID from the request body and trusts it; take the user ID from the verified session instead.
Quick summary table
| Check | Pass looks like |
|---|---|
| RLS | On for every table in public |
| Policies | No true rules on private tables; with check on writes |
| Two accounts | B cannot see or change A’s data |
| Keys | No service role key in code or bundle |
| Storage and views | Private files in private buckets; views respect RLS |
| Auth settings | Email confirmation on; tight redirect list |
| Own routes | Reject calls without a valid session |
What this checklist does not cover
It covers the most common database-access mistakes. It does not cover payments logic, injection bugs, vulnerable dependencies, or a determined attacker. If your app holds health, financial or other regulated data, a professional penetration test is the right step and a checklist is not a substitute.
Prefer someone else to look?
We offer a $100 Vibe-Check: we read your code and settings and send a written report with ranked findings and a copy-paste fix for each. This is our own paid service; we earn money if you buy it. It is AI-assisted, best-effort, not a guarantee and not a penetration test. See the Vibe-Check.
Leave a Reply