Guide

Supabase RLS checklist for vibe-coded apps

Most data leaks in vibe-coded apps are not clever hacks. Someone takes the Supabase URL and anon key from the app's JavaScript (both are public by design) and asks the database for rows. If row level security (RLS) is missing or too loose, Supabase hands the rows over.

This checklist is for your own project. Work through it in order. Each step says what to check and how to fix it.

1. Every exposed table has RLS turned on

Supabase exposes tables in the public schema through its REST API. Without RLS, anyone with the anon key can read and write them. Tables made in the Table Editor have RLS on by default, but tables created by raw SQL or migrations written by an AI assistant often do not.

Run this in the SQL Editor:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;

Any row with rowsecurity = false needs fixing:

alter table public.your_table enable row level security;

RLS turned on with no policies means nobody except the service key can access the table. That is a safe starting point. Then add only the policies you need.

Also open Advisors in the Supabase dashboard and run the Security Advisor. It flags tables without RLS, risky views and functions with mutable search paths. Fix every red item.

2. Policies check ownership, not just "logged in"

A very common AI-written policy looks like this:

create policy "Users can read" on public.orders
for select to authenticated using (true);

That lets every logged-in user read every order. If sign-up is open, "authenticated" means "anyone with an email address." A safer version ties rows to the user:

create policy "Users read own orders" on public.orders
for select to authenticated
using ((select auth.uid()) = user_id);

Wrapping auth.uid() in (select ...) is Supabase's recommended form because Postgres evaluates it once per query instead of once per row.

List your policies and read each one out loud:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'public';

Watch for true, policies granted to anon on private data, and policies that only check auth.role() = 'authenticated'.

3. Inserts and updates use WITH CHECK

USING decides which existing rows a user can see or touch. WITH CHECK decides what a new or changed row is allowed to look like. Without a good check on inserts, a user can create rows with someone else's user_id.

create policy "Users insert own rows" on public.posts
for insert to authenticated
with check ((select auth.uid()) = user_id);

create policy "Users update own rows" on public.posts
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

If an update policy has no WITH CHECK, Postgres reuses the USING expression, which is usually what you want. Writing it out explicitly makes the intent clear and avoids surprises when someone edits the policy later.

4. Users can't edit their own role or plan

RLS works on rows, not columns. If users can update their own row in profiles, and that row has is_admin, plan or credits, they can change those too. One API request is enough.

Fixes, from simplest to most flexible:

  • Move sensitive fields to a separate table that users can read but never write.
  • Use column privileges: revoke update on public.profiles from authenticated; then grant update (display_name, avatar_url) on public.profiles to authenticated;
  • Use a trigger that rejects changes to protected columns unless they come from the service role.

5. Authorization uses app_metadata, never user_metadata

Supabase Auth stores two kinds of metadata on each user. This difference matters a lot:

  • user_metadata (raw_user_meta_data) can be changed by the user at any time with supabase.auth.updateUser({ data: { role: 'admin' } }). Use it for display names and avatars only.
  • app_metadata (raw_app_meta_data) can only be changed with the service key on a server. This is where roles and plans belong.

Search your policies and code for user_metadata. A policy like (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' lets any user make themselves an admin. The safe version reads app_metadata:

using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')

Note that JWT claims only update when the token refreshes, so a removed role can linger until then. For instant changes, check a roles table instead.

6. Security definer functions are locked down

A function marked security definer runs with the permissions of its owner, which in Supabase is usually postgres. It skips RLS entirely. AI assistants often reach for it to "fix" a permission error.

Functions in the public schema can be called by anyone through /rest/v1/rpc/function_name. Postgres also grants execute on new functions to everyone by default. Find them:

select n.nspname, p.proname
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef and n.nspname = 'public';

For each one, decide if it really needs to bypass RLS. If it does:

  • Move it to a schema that is not exposed through the API, such as private.
  • Set a fixed search path: set search_path = '', and use fully qualified names like public.orders inside.
  • Revoke access: revoke execute on function private.fn from public, anon, authenticated; then grant it only to the roles that need it.
  • Inside the function, check auth.uid() yourself, since RLS won't do it for you.

7. Views use security_invoker

By default a Postgres view runs with its creator's permissions, so a view over a protected table can quietly bypass RLS. On Postgres 15 and later (all current Supabase projects), fix it with:

alter view public.your_view set (security_invoker = true);

New views should be created with create view ... with (security_invoker = true). Materialized views cannot have RLS at all, so keep them out of the public schema or revoke select on them from anon and authenticated.

8. Storage buckets have policies too

Files live in storage.objects, which has its own RLS. A public bucket lets anyone download a file if they know or guess the path. Use private buckets for user uploads, invoices or anything personal, and write policies that check the folder name against the user ID, for example (storage.foldername(name))[1] = (select auth.uid())::text.

9. Test with the anon key, like a stranger would

Reading policies is good. Testing them is better. Use your own project URL and the anon or publishable key from your app:

curl "https://YOUR-REF.supabase.co/rest/v1/profiles?select=*&limit=5" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer YOUR_ANON_KEY"

An empty array [] means anonymous users can't see rows. Real data means they can. Repeat for every table, and try an rpc/ call for each function in public.

Then test as a logged-in user. Create two test accounts, A and B. Sign in as B, copy B's access token from the browser (it's in local storage under a key starting with sb-), and use it as the Authorization header. Try to read A's rows, update A's rows, and insert a row with A's user_id. All three should fail. Do this on a staging project or with test data, never against real users' records.

10. Keep the service key on the server

The service role key ignores every rule on this list. It belongs only in Edge Functions or server code, never in the frontend. Our guide to checking your bundle for leaked keys shows how to confirm it isn't there.

FAQ

Is RLS enabled by default in Supabase?

Tables created in the Supabase Table Editor have RLS enabled by default. Tables created with raw SQL or migrations may not, so check pg_tables for rowsecurity = false.

Should I use user_metadata or app_metadata for roles?

Use app_metadata. Users can change their own user_metadata from the browser with supabase.auth.updateUser, so any policy that trusts it can be bypassed.

Do Postgres views respect RLS?

Not by default. A view runs with its owner's permissions unless you set security_invoker = true, which is available on Postgres 15 and later.