Check 01
Row Level Security is off on a table.
The problem. A table in your database has Row Level Security (RLS) turned off.
Why it matters. The Supabase anon key (called the publishable key in new projects) ships in your frontend and is public by design. With RLS off, that key is all a stranger needs to read, and often change, every row in the table.
Check it yourself (2 minutes)
- Open your project in the Supabase dashboard and go to the Table Editor.
- Look at every table in the
public schema. Any table without RLS enabled (Supabase marks these as unrestricted) is readable by anyone with the key.
- Or run this in the SQL Editor. Every row it returns is a table with RLS off:
select tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
Fix
Enable RLS on every table, then write policies (check 02). With RLS on and no policies, the app can no longer read the table, so do this on a staging copy first.
alter table public.profiles enable row level security;
Check 02
RLS is on, but the policy is true.
The problem. A policy such as using (true) lets every request through, which is the same as having no RLS at all.
Why it matters. The dashboard shows RLS as enabled, so the table looks protected. Policies only protect data when they compare the row to the signed-in user, for example using (user_id = auth.uid()).
Check it yourself (2 minutes)
- List your policies in the SQL Editor and look for
true in the qual or with_check columns:
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public';
- Create two test users, A and B, and add a row that belongs to B.
- Sign in as A, copy A's access token from your browser's developer tools, and ask for B's row:
curl "https://<project>.supabase.co/rest/v1/notes?user_id=eq.<user-b-id>" \
-H "apikey: <anon-or-publishable-key>" \
-H "Authorization: Bearer <user-a-access-token>"
- An empty list
[] is the right answer. If B's row comes back, A can read other people's data.
Fix
Rewrite the policies table by table, one each for select, insert, update and delete, so every one compares to auth.uid():
create policy "read own" on public.notes
for select to authenticated using (user_id = auth.uid());
create policy "add own" on public.notes
for insert to authenticated with check (user_id = auth.uid());
create policy "edit own" on public.notes
for update to authenticated
using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy "delete own" on public.notes
for delete to authenticated using (user_id = auth.uid());
Check 03
A secret is in the frontend.
The problem. A key that should only live on a server, such as the Supabase service role key, a Stripe live secret key or an AI model API key, is in the code that ships to the browser.
Why it matters. Anyone can read your frontend bundle. The service role key skips RLS entirely, and a model API key lets a stranger run up your bill.
Check it yourself (2 minutes)
- Search the repo for key names and prefixes:
git grep -nE "service_role|sb_secret_|sk_live_|OPENAI_API_KEY|ANTHROPIC_API_KEY"
- Build the app and run the same search on the build output (often
dist or .next), or search your live site in the browser's developer tools under Sources.
- Any variable starting with
VITE_ or NEXT_PUBLIC_ is bundled into the frontend on purpose. None of them should hold a secret.
Fix
Move the call that needs the secret into an edge function or a server route, and keep the key in that server's environment variables. If a key was ever committed or deployed, rotate it with the provider: deleting it from the code is not enough, because it stays in git history and in old builds.
Check 04
Users can make themselves admin.
The problem. A role or is_admin column sits on a table, such as profiles, that users are allowed to update.
Why it matters. A policy that lets users edit their own row usually lets them edit every column in it, including the one that decides who is an admin.
Check it yourself (2 minutes)
- Sign in as a normal test user and copy the access token, as in check 02.
- Try to change your own role:
curl -X PATCH "https://<project>.supabase.co/rest/v1/profiles?id=eq.<your-user-id>" \
-H "apikey: <anon-or-publishable-key>" \
-H "Authorization: Bearer <your-access-token>" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"role":"admin"}'
- If the response shows your row with
"role":"admin", any user can promote themselves.
Fix
Move roles to their own table that users can read but not write, or stop users updating that column by granting update only on the columns they may change:
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;
Check 05
Edge functions do not check who is calling.
The problem. An edge function trusts a user_id sent in the request body, or runs without checking for a signed-in user.
Why it matters. Edge functions often use the service role key, so RLS does not apply inside them. Whatever user the function believes it is serving, it can read and change that user's data.
Check it yourself (2 minutes)
- Call the function with no Authorization header:
curl -i -X POST "https://<project>.supabase.co/functions/v1/<function-name>" \
-H "Content-Type: application/json" -d '{}'
- A
401 is the right answer. Anything that does work is a problem.
- Then call it with your own access token and another test user's
user_id in the body. If it acts for that other user, it trusts the body.
Fix
Keep JWT verification on for the function, and read the user from the verified token, never from the body. The anon key is itself a valid token, so also confirm a real user came back:
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return new Response("Unauthorized", { status: 401 });
// use user.id from here on, never body.user_id
Check 06
Public storage buckets hold private files.
The problem. Uploads such as invoices, ID photos or private documents sit in a storage bucket marked public.
Why it matters. Files in a public bucket can be opened by anyone with the URL, without signing in, and URLs leak through emails, logs and shared screenshots.
Check it yourself (2 minutes)
- In the Supabase dashboard, open Storage and note which buckets are marked public.
- Copy the URL of a file a user uploaded and open it in a private browser window where you are not signed in.
- If a file that should be private loads, the bucket is exposing it.
Fix
Make the bucket private, add storage policies so users can reach only their own files, and serve files through short-lived signed URLs:
const { data } = await supabase.storage
.from("documents")
.createSignedUrl(path, 60); // valid for 60 seconds
Check 07
Payment webhooks skip signature checks.
The problem. The endpoint that receives payment events from Stripe, or another provider, accepts any request without checking it came from the provider.
Why it matters. Anyone who finds the URL can send a fake "payment succeeded" event and unlock paid features or mark orders as paid.
Check it yourself (2 minutes)
- Find your webhook URL in the Stripe dashboard under Developers, Webhooks.
- Send it an empty, unsigned request:
curl -i -X POST "https://<your-app>/<webhook-path>" \
-H "Content-Type: application/json" -d '{}'
- You want a
400 that rejects the missing signature. Anything else, including a 200 or a 500, means the endpoint is not checking.
Fix
Verify the signature on the raw request body with the webhook secret before doing anything else, using constructEvent (or constructEventAsync in edge functions) or your provider's equivalent:
const event = stripe.webhooks.constructEvent(
rawBody, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET
); // throws on a bad signature: return 400
Bonus
The problem. Built-in scanners, such as Lovable's security scan or Supabase's Security Advisor, are easy to run and easy to over-trust.
Why it matters. A scanner can tell you RLS is on. It cannot tell you whether user A should see user B's row, because that depends on what your app is for.
- Run the scan your platform offers. In Supabase, open Advisors and then Security Advisor.
- Fix every error and read every warning.
- Then run checks 02, 04 and 05 by hand. A clean scan does not cover them.
Treat the scan as a floor, not a sign-off. Re-run it after every schema change.