SecurityCritical risk
5 Common RLS Mistakes in Production
Five Supabase Row Level Security mistakes that slip into apps built fast: RLS left off, open writes, trusted user IDs, leaked secret keys and side doors.
If you built your app on Supabase, your publishable key (the older name is the anon key) is sitting in your front-end code right now. That's normal. Supabase describes the publishable key as safe to expose online. What makes that true is Row Level Security, or RLS: rules inside Postgres that decide which rows each request can see and change.
Supabase auto-generates an API directly from your database schema. Grants decide whether a role can reach a table at all. Your RLS policies decide which rows come back. Get them slightly wrong and your app still works perfectly for every honest user. It just also works for the dishonest one.
One recent change: new tables in the public schema no longer get API access automatically. That's the default for new projects since May 30, 2026, and for existing projects from October 30, 2026. Existing tables keep their grants, and Supabase says RLS behavior is unchanged. Once you grant access, your policies are all that stands between one user's data and everyone else.
This isn't a niche problem either. Broken Access Control is number one in the OWASP Top 10:2025, and OWASP's data found some form of it in every application tested. In 2025, CVE-2025-48757 described insufficient RLS policies in sites generated by Lovable that let unauthenticated attackers read or write database tables. Lovable disputes it, saying each customer is responsible for protecting their own app's data. Either way, the policies are yours to get right.
Here are five RLS mistakes we look for first, and how to fix each one.
1. RLS is switched off on a table
Supabase's docs are plain: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. The Security Advisor describes the result as a table anyone with your project URL can read, edit and delete, and the production checklist makes the same point.
RLS isn't switched on automatically for every new table (Supabase points to event triggers if you want that), so a table created by a migration or an AI coding tool can go live without it. Or RLS gets switched off "just to make it work" and never comes back. A sneakier version: you wrote policies but never enabled RLS, so they have no effect. The Security Advisor flags both.
The new grants default adds a twist: you grant a table to authenticated to get a screen working and forget RLS. Do both in one migration, as Supabase's own example does:
grant select, insert, update, delete on public.notes to authenticated;
alter table public.notes enable row level security;
Once RLS is on, Postgres denies every row until a policy allows it. That's OWASP's advice too: deny by default.
2. Reads are locked down, writes aren't
You wrote a careful SELECT policy. Then saving a note does nothing, because with RLS on and no UPDATE policy, Postgres skips the rows without raising an error. Under deadline pressure, the tempting "fix" is a blanket policy with using (true) for every action.
That one line undoes your work. Postgres combines permissive policies with OR, so if any single policy says yes, the answer is yes.
The second trap is subtler. An UPDATE policy that limits people to their own row still lets them change every column in that row. If role, plan or credits lives in your profiles table, a user can promote themselves with one request.
Write one policy per action, name the role with to authenticated, and check ownership every time, following Supabase's RLS guide:
create policy "Owners read their notes" on public.notes
for select to authenticated
using ( (select auth.uid()) = user_id );
create policy "Owners add their notes" on public.notes
for insert to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Owners edit their notes" on public.notes
for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Owners delete their notes" on public.notes
for delete to authenticated
using ( (select auth.uid()) = user_id );
Supabase also notes that an UPDATE needs a matching SELECT policy. For roles, it recommends a dedicated table users can't write to over column-level privileges, which it calls an advanced feature.
3. Trusting the user ID the browser sends
A common pattern in apps built fast: the front end queries with .eq('user_id', currentUser.id) and everyone assumes that's the security. It's really a filter the browser chose to apply, and anyone can edit the request. OWASP says access control is only effective in trusted server-side code, where an attacker can't modify the check. With Supabase, that trusted code is your RLS policy.
Inside a policy, identity should come from auth.uid(), which returns the ID of the user making the request. That's why the insert policy above uses with check. If User A tries to create a row carrying User B's ID, Postgres throws an error instead of saving it.
Same trap, one level up: users can update their own user_metadata, so Supabase says it's not a good place for authorization data. Use app_metadata, which they can't change.
4. The service_role key ships to the browser
Supabase has two kinds of keys. Publishable keys are low-privilege. Secret keys, and the legacy service_role key they replace, bypass Row Level Security. Supabase's rule is blunt: never put one in a browser, a shipped app or source control. It also plans to deprecate the legacy anon and service_role keys by the end of 2026, so it's a good time to switch.
The easy way to leak one is an environment variable. In Next.js, anything prefixed NEXT_PUBLIC_ is inlined into the JavaScript sent to the browser. In Vite, VITE_ variables are exposed in client-side code. One wrong prefix, or a quick fix that swaps in the secret key to make an admin query work, and your policies stop mattering.
Supabase blocks secret keys sent from a browser with a 401, but an attacker can still use the key from other tools. If one was ever exposed, fix the leak, then rotate: create a new key, replace it everywhere, and delete the old one (or deactivate a legacy service_role key).
5. Side doors: public buckets, views and functions
Your tables can be locked tight while data walks out around them.
Public storage buckets
A public bucket skips access control when files are read: anyone with the URL can open them. Fine for avatars and blog images; wrong for ID scans, invoices or anything uploaded privately. Buckets are private by default, and private buckets run every operation through RLS policies on storage.objects. These policies, adapted from Supabase's storage access control guide, let each user upload only into a folder named after their own ID and read back only their own files:
create policy "Users upload to their own folder" on storage.objects
for insert to authenticated
with check (
bucket_id = 'documents' and
(storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
create policy "Users read their own files" on storage.objects
for select to authenticated
using (
bucket_id = 'documents' and
(select auth.jwt()->>'sub') = owner_id
);
Views
Supabase notes that views bypass RLS by default, because they're usually created by the postgres user. On Postgres 15 and above, create them with security_invoker so they follow the policies of the tables underneath:
create view public.note_titles
with (security_invoker = true)
as select id, title, user_id from public.notes;
Security definer functions
A security definer function runs with the privileges of whoever created it. If it lives in an exposed schema, Supabase warns that it's callable over the Data API with the creator's privileges. Keep them in a schema the API doesn't expose and, as Supabase's functions guide says, set search_path = '' and name the schema for every table inside them.
The two-account test
Reading policies only gets you so far. The real test is trying to get in from an account that shouldn't.
- Create User A and User B, each with a few rows.
- Signed in as A, request one of B's rows by its ID. You should get an empty result.
- Try to update and delete that row. You should get zero rows back.
- Try to insert a row with
user_idset to B's ID. It should fail with an error. - Try to change your own
roleorplan. It should be refused. - Sign out and repeat with only the publishable key. Nothing private should come back.
- Open a private file's URL in a signed-out browser. It shouldn't load.
Here's what steps 2 to 4 look like with supabase-js:
// Signed in as User A. bNoteId and bUserId belong to User B.
const read = await supabase.from('notes').select('*').eq('id', bNoteId)
console.log(read.data) // expect: []
const edit = await supabase.from('notes')
.update({ title: 'changed' }).eq('id', bNoteId).select()
console.log(edit.data) // expect: []
const add = await supabase.from('notes')
.insert({ user_id: bUserId, title: 'planted' })
console.log(add.error) // expect: a row-level security error
One gotcha: a blocked update or delete usually doesn't throw an error, because Postgres silently skips rows a policy hides. So chain .select() to get the changed rows back and check that the list is empty. Checking for an error alone would tell you everything passed.
Rerun it whenever you add a table, view, bucket or function.
Where a pre-launch audit helps
None of this is exotic. It's just easy to miss when you're shipping fast, because every one of these mistakes is invisible from the happy path.
Looking for them is our job. With your written permission first, a LaunchGate audit runs cross-account tests like the ones above, reviews your policies, views, functions and buckets, looks for keys in the code you ship, and reports what we found, with evidence and a recommended fix. We report on what we tested; we don't claim your app is free of problems outside that scope. Our methodology explains what is and isn't covered.
For a first look, the Free Launch Check is a preliminary public-surface review — not a full audit. It only sees what's public, so it can't test your policies from the inside. For that, compare the audit packages. Still building? Try our vibe-coding security checklist.
Article checklist · 10 items
Check your own app
Tick each item once you have checked it on your app. Ticks stay in this browser only.
Your article check 0 / 10 complete
Want LaunchGate to check the rest?
Get a free launch checkThe Free Launch Check is a preliminary public-surface review — not a full audit.
References
- Row Level Security Supabase Docs
- Breaking Change: Tables not exposed to Data and GraphQL API automatically Supabase Changelog
- Understanding API keys Supabase Docs
- REST API Supabase Docs
- Performance and Security Advisors Supabase Docs
- Production Checklist Supabase Docs
- Database Functions Supabase Docs
- Column Level Security Supabase Docs
- Storage Buckets Supabase Docs
- Storage Access Control Supabase Docs
- JavaScript reference: Update data Supabase Docs
- A01:2025 Broken Access Control OWASP Top 10:2025
- Row Security Policies PostgreSQL Documentation
- CREATE POLICY PostgreSQL Documentation
- CVE-2025-48757 NIST National Vulnerability Database
- How to use environment variables in Next.js Next.js Docs
- Env Variables and Modes Vite Docs
This article is general information, not legal advice. LaunchGate identifies issues based on the checks performed; it is not a law firm and does not provide legal advice. Some findings may require attorney or specialist review.