What is row-level security?
The single most common gap we find in AI-built apps, explained in plain terms.
In short
Row-level security (RLS) is a database feature that decides, row by row, which users may read or change data, through policies the database enforces on every query. Because the database applies the rule, it holds however a request arrives: through your app, a direct API call or someone else's script.
Also called: RLS, Row security policies
Why does RLS matter more in AI-built apps?
A generated app usually looks correct because the interface only shows each person their own data. That is a display decision, not a security one. Apps built on Supabase let the browser query the database directly with a public key, so without row-level security anyone who copies that key can read everything in the table.
This is not hypothetical. In 2025 a vulnerability was published, CVE-2025-48757, describing Lovable-generated sites whose insufficient row-level security let unauthenticated users read or write database tables. Lovable disputes it, but the class of mistake it describes is the one we find most often.
What does a row-level security policy actually decide?
A policy answers one question per operation: given this user, may they select, insert, update or delete this row? Enabling security with no policy denies everything; the PostgreSQL documentation calls this default deny. Writing policies without enabling security protects nothing. Both halves are required, on every table the browser can reach.
What does a row-level security policy look like?
A policy is a named rule attached to one table and one action. The most common one in a multi-user app is “a person may read only their own rows”: the policy allows a select when the row's owner column equals the signed-in user's id, which Supabase exposes as auth.uid().
The Supabase documentation recommends adding an index on that owner column and wrapping the auth call as (select auth.uid()) so the check runs once per query, not once per row. You write one policy per action you want to allow (select, insert, update, delete); anything you do not write a policy for stays denied.
| SQL statement | What it does |
|---|---|
| alter table public.notes enable row level security; | Turns row-level security on for the table. From here every query through the API is denied unless a policy allows it. |
| create policy "Owners read their notes" on public.notes for select to authenticated using ((select auth.uid()) = user_id); | Lets a signed-in user read a row only when its user_id column holds their own id. |
| create policy "Owners add their notes" on public.notes for insert to authenticated with check ((select auth.uid()) = user_id); | Lets a signed-in user add a row only if it is stamped with their own id, so nobody can write rows in someone else's name. |
| create index on public.notes (user_id); | Indexes the column the policies filter on, which Supabase recommends so the policy check stays fast as the table grows. |
The to authenticated clause matters as much as the condition. It limits the policy to signed-in users, so a signed-out visitor using the public key (the anon role in Supabase) is never even considered by it. An update policy usually needs both halves: using to decide which existing rows a user may touch, and with check to decide what the changed row may look like afterwards.
What mistakes leave row-level security open?
Four mistakes leave row-level security open: a policy for reading but not for writing, comparisons with auth.uid() that ignore signed-out visitors, testing as the table owner, who normally skips the rules, and stacking permissive policies without knowing that one loose policy opens the whole table however strict the others are.
- Writing a policy for reading but not for writing. In PostgreSQL, a USING expression decides which existing rows a command can see or change, and WITH CHECK decides what a new or updated row is allowed to look like. Leave out the second and a user may be able to write rows they should not.
- Comparing against auth.uid() without handling signed-out visitors. Supabase notes that auth.uid() returns null when no user is signed in, and recommends an explicit auth.uid() IS NOT NULL check rather than relying on the comparison.
- Testing as the table owner. PostgreSQL lets superusers, roles with BYPASSRLS and normally the table's owner skip row security, so a query that works in an admin console proves nothing about what a browser can do.
- Stacking policies without knowing how they combine. Permissive policies, the default, are joined with OR, so one loose policy opens a table however strict the others are; restrictive policies are joined with AND.
How do I test a policy as a real user?
The dashboard tells you whether security is switched on; only a query run as a real user tells you whether a policy is right. The Supabase SQL editor runs as the postgres role, which bypasses row-level security, so a query there shows every row whatever the policies say. Switch role inside a transaction first.
- 01Start a transaction and switch roleRun begin; and then set local role authenticated; so the query runs with a browser's permissions. The local keyword ends the change with the transaction, so the session cannot be left in the wrong role by accident.
- 02Act as one test userIn Supabase, auth.uid() reads the user id from the request's JWT claims, so set them for the transaction with select set_config('request.jwt.claims', '{"sub":"your-test-user-uuid","role":"authenticated"}', true); using the id of a real test account.
- 03Query, try a forbidden write, then roll backSelect from the table and confirm only that user's rows come back. Try to insert or update a row carrying another user's id and confirm it is refused. End with rollback; so nothing is kept, then repeat with set local role anon; to see what a signed-out visitor gets.
Checks you run by hand catch today's gaps; automated tests catch the policy a later migration loosens. Supabase supports pgTAP, a testing framework for Postgres, and its command line runs those SQL test files against a local database with supabase test db. A useful test per table asserts both sides: the rows a user should see, and the rows and writes they must not get. That is how Looph keeps its 1,180 policies honest, with 110 pgTAP suites.
How can I check whether my own tables have it?
You can check from the Supabase dashboard. The Table Editor marks any table with row-level security turned off, the Security Advisor flags exposed tables without it, and then you confirm that each table the browser reaches has security on and at least one policy for each action you allow.
- 01Open the Table EditorIn your Supabase project, open Table Editor. Supabase marks any table that has row-level security turned off, because a browser with the public key can read or write those tables freely.
- 02Run the Security AdvisorOpen Database, then Advisors, then Security. It flags every table that is exposed through the API with row-level security disabled, which is the exact gap an attacker looks for.
- 03Confirm each table has a policy, not just the switchTurning security on with no policy denies everyone, which breaks the app; writing a policy without turning security on protects nothing. For each table the browser reaches, confirm security is on and at least one policy exists for each action you allow.
If your app serves more than one customer or workspace from the same tables, this is also what keeps one tenant's data out of another's — see multi-tenancy. And remember the two keys behave differently: the public key the browser uses is bound by these policies, while the service role key ignores them entirely, so it must stay on the server (see the anon key vs the service role key). For the policies themselves, Supabase RLS policy examples shows the common patterns. If you would rather have someone check every table and policy for you, a production readiness check covers row-level security first, and a vibe coding rescue fixes what it finds.
Common questions
Does enabling row-level security slow the database down?
Barely, when it is set up well. Supabase recommends an index on every column your policies filter on, and wrapping auth.uid() as (select auth.uid()) so it is evaluated once per statement instead of once per row. Without row-level security, anyone holding the public key can read reachable tables.
Does the Supabase service role key respect row-level security?
No. It bypasses row-level security entirely, which is why it must only ever be used on a server. Anyone holding it can read or change every row, whatever the policies say, so it must never reach the browser, where the public key lives.
What happens if I turn on row-level security without any policies?
Nothing gets through. PostgreSQL applies a default-deny policy when a table has row security enabled and no policy, so no rows are visible or can be changed. Supabase says the same for its API: no data is accessible with the publishable key until you create policies.
Does row-level security apply to the table owner?
Normally not. PostgreSQL lets superusers and roles with the BYPASSRLS attribute skip row security always, and table owners usually skip it too, unless the table is altered with FORCE ROW LEVEL SECURITY. That is why testing policies from an admin console can hide real gaps.
Go deeper
Supabase RLS policy examples and mistakesSupabase RLS policy examples for owner, team, public, admin and storage access, with a test for each and common mistakes that break or open your tables.
Related terms
Read next
Sources
- PostgreSQL documentation: Row Security Policies
- Supabase documentation: Row Level Security
- NVD: CVE-2025-48757 (disputed by the supplier)
Sources checked October 24, 2026
More on this: Production architecture & security · All glossary terms
Built something in Lovable you want people to rely on?
We are the engineers who take it the rest of the way — secured, tested, released and supported.
