In a Supabase app, the browser talks to your database directly, with a key that is public by design. The only thing between any visitor and your data is the database's own access rules: table grants and row-level security (RLS) policies.
AI coding tools are good at writing policies that make a feature work. They are less reliable at writing policies that stop the wrong user. The dashboard shows RLS as enabled, and the data is still open.
This guide explains how Supabase access control works, the RLS mistakes common in AI-built apps, and how to find, fix and test each one.
The short answer
- Enable RLS on every table in an exposed schema, usually
public, and grant each role only the operations it needs. - Write one policy per operation, name the role it applies to, and tie rows to
auth.uid()or to membership data the user can't edit. - Check with tools, then by hand. Run the Security Advisor, read every policy, and try to reach another account's data with a second test user.
- Lock it in with tests, so a later change can't quietly reopen a table.
How Supabase decides who can see what
Every request passes two checks inside Postgres. Grants decide whether a role may run an operation on a table at all. Policies decide which rows that operation applies to. You need both.
The key a request carries decides its role:
| Key | Where it belongs | Postgres role | RLS applies? |
|---|---|---|---|
Publishable key (legacy: anon) | Browser and mobile apps | anon, or authenticated once the user signs in | Yes |
Secret key (legacy: service_role) | Servers, Edge Functions, jobs | service_role | No, it bypasses RLS |
Supabase's API keys guide says the legacy keys are being deprecated by the end of 2026. The model doesn't change: the public key reaches only what RLS allows, and the secret key reaches everything.
Two defaults explain many of the gaps:
- Tables created in the dashboard have RLS enabled. Tables created in SQL don't. AI tools usually create tables through SQL migrations, so RLS has to be switched on explicitly.
- Grants are broad. Supabase's guide to securing your API notes that on existing projects, new tables in
publicgrantselect,insert,updateanddeletetoanonandauthenticated. Supabase is moving to opt-in grants, so check what your project does.
Eight common RLS mistakes and how to check for each
Several of these map to a named check in the Supabase Security Advisor.
1. RLS switched off on a table
A table in an exposed schema without RLS can be read and changed by anyone with the public key. The Security Advisor flags it as an error (0013_rls_disabled_in_public).
-- Tables in the public schema with RLS disabled
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;Fix: enable RLS, revoke the default grants, and grant back only what each role needs, in the same migration.
alter table public.invoices enable row level security;
revoke all on table public.invoices from anon, authenticated;
grant select, insert, update, delete on table public.invoices to authenticated;2. Policies that allow everything
When a query fails during development, the quickest way to clear the error is a policy like using (true). RLS stays enabled, so the dashboard looks fine, but every user with that role can now see or change every row. The Security Advisor reports this as 0024_permissive_rls_policy.
using (true) is fine for genuinely public data, such as published articles. Anywhere else, use a condition tied to the user, such as (select auth.uid()) = user_id.
3. Policies with no role, or one policy too many
A policy without a to clause applies to every role, including signed-out visitors. Name the role every time, usually to authenticated.
Permissive policies, the default, are combined with OR, as the PostgreSQL documentation explains. A second, broader policy added to fix one screen widens access for every query on that table. Conditions that must always hold, such as "only members of this organization," belong in a restrictive policy, which is combined with AND.
4. Authorization based on data the user can edit
Some policies check a role or team stored in user_metadata. Users can update that field themselves, so anyone can promote themselves. The Security Advisor flags this as 0015_rls_references_user_metadata.
Fix: keep authorization data in app_metadata, which users can't change, or in a membership table they can't write to. Token claims only update when the session refreshes, so for sensitive actions, check the table rather than the token.
5. Row rules that ignore columns
RLS controls rows, not columns. A policy that lets users update their own profile lets them update every column in it, including role, plan or credits if those live there.
Fix: move privileged fields to a table that only server code writes, or limit updates with column-level grants such as grant update (display_name, avatar_url). Also check that each update policy's with check clause still ties the row to the user. A with check (true) lets users hand a row to someone else.
6. Views and functions that skip RLS
Views run with their creator's privileges by default. On Supabase that is usually postgres, which bypasses RLS, so a view over a protected table can return every row. On Postgres 15 and later, create views with (security_invoker = true).
Functions declared security definer also run with their owner's privileges. In an exposed schema, any client can call them through the API. Keep them in an unexposed schema, set search_path = '', and grant execute only to the roles that need it.
7. Storage buckets with loose rules
File access is controlled by policies on storage.objects, with the same failure modes. A policy that checks only the bucket name lets any user read or overwrite any file in it. A public bucket with a broad select policy lets anyone list its files. Tie paths to the user instead, for example a top-level folder named after their user ID, as shown in Supabase's Storage access control guide.
8. Server code that uses the secret key for user requests
When a server route uses the secret key to handle a user's request, your policies no longer protect that path. The route has to check the user's permissions itself. Where possible, use a client that carries the user's session so RLS still applies. A secret key in the browser is worse; our list of security mistakes in AI-built apps covers how to find and rotate one.
How to audit your project's RLS in an hour
- Run the Security Advisor in the dashboard, or
supabase db advisorsfrom the CLI. Fix errors first, then review warnings against your intended access model. - Read every policy with the query below. Look for
{public}inroles, meaning no role was named, andtrueinqualorwith_check. - Test across accounts. Sign in as one test user, copy a request from the browser's network tab, and change the IDs to a second user's records. Reads should return nothing and writes should fail.
- Check storage. Note which buckets are public, and repeat the cross-account test on file paths.
- Search server code for the secret key, and confirm each route that uses it checks permissions itself.
-- Every policy in the public schema, with its roles and conditions
select tablename, policyname, permissive, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;Don't trust the SQL Editor alone
Dashboard queries run as the postgres role by default, which bypasses your policies. A query that works there says nothing about what a signed-in user can reach. Switch the editor's role selector to a user, or test through the API with real sessions.
How to keep RLS policies from regressing
Every new feature, and every large AI-assisted change, can add a table or loosen a policy. Supabase runs database tests written in pgTAP with supabase test db, and its RLS guide recommends one test file per table. A minimal example for a hypothetical invoices table:
-- supabase/tests/invoices_rls.test.sql
begin;
select plan(2);
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
('22222222-2222-2222-2222-222222222222', 'other@example.com');
insert into public.invoices (user_id, total)
values ('11111111-1111-1111-1111-111111111111', 100);
set local role authenticated;
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
select is_empty(
$$select * from public.invoices$$,
'another user reads no invoices'
);
select throws_ok(
$$insert into public.invoices (user_id, total)
values ('11111111-1111-1111-1111-111111111111', 1)$$,
'42501', null,
'another user cannot create an invoice for the owner'
);
select * from finish();
rollback;The test inserts a real row before checking that it's hidden, because an empty result proves nothing on an empty table. Run the suite in CI, alongside the checks in our guide to taking an AI-built prototype to production.
Common mistakes when fixing RLS
- Loosening a policy when the problem is a grant. A missing grant returns a permission error (
42501). A policy that filters rows returns an empty result. Check grants first. - Fixing it in the front end. Hiding a button or filtering results in the client changes nothing, because anyone can call the API directly.
- Forgetting new tables. Add "RLS enabled, grants set, policies tested" to the pull request checklist for every migration.
- Ignoring performance. Index the columns your policies filter on, and write
(select auth.uid())so Postgres evaluates it once per query rather than once per row.
A checklist for your next review
- Every table in
publichas RLS enabled, and grants match what the app does. - Every policy names its role and has a user-specific condition, unless the data is public.
- No policy reads
user_metadata, and privileged columns can't be edited by their owners. - Views use
security_invoker, and security definer functions live outside exposed schemas. - Storage policies are tied to user paths, and public buckets can't be listed.
- Every use of the secret key sits behind a server-side permission check.
- Policy tests run in CI.
When to bring in a second reviewer
These checks catch common configuration errors. They don't catch business-logic flaws, such as an invite flow that lets users join any organization. If your app holds customer or payment data, or a launch or enterprise deal is coming, an independent review is worth considering. Our Security Audit for AI-Built Apps covers Supabase access rules alongside secrets, authentication, storage and hosting, with a prioritized fix list.
Frequently asked questions
Is it safe to expose the Supabase publishable or anon key?
Yes. It identifies your app, not a user, and is meant to be public. That only holds if RLS and grants are correct on every exposed table, because they are what limit the key.
Do I need RLS if all database access goes through my own server?
If no client uses the Data API, you can turn it off or keep tables in a schema that isn't exposed. Enabling RLS anyway is a sensible extra layer in case a table is exposed later.
Can AI coding tools write correct RLS policies?
They can draft them quickly. Treat the output like any other authorization code: read every policy, test it with two accounts, and keep the tests in the repository.