Guide · September 6, 2026
7 row level security mistakes that leave your database public
When your frontend talks to Postgres over an auto-generated API, row level security is your access control. There is no other gate. Get a policy wrong and every row of that table is one HTTP request away from anyone who opens your site — no exploit required.
1. RLS is never enabled
A table created without ALTER TABLE … ENABLE ROW LEVEL SECURITY is readable and writable by anyone the API grants access to. This is the single most common critical finding in AI-generated apps. Enable RLS on every table in your public schema, then add policies — in that order.
2. A policy that passes for everyone
USING (true) is how assistants make an error go away. It re-opens the table completely. Scope reads and writes to the caller instead:
create policy "own rows"
on public.notes for select
to authenticated
using (auth.uid() = user_id);3. SELECT is locked down but INSERT and UPDATE aren't
Policies are per-command. A read policy does nothing for writes. Without a WITH CHECK clause on insert and update, a user can create rows owned by someone else — or reassign yours to themselves. Write a policy for each of select, insert, update and delete, and always pair updates with WITH CHECK.
4. Roles stored on the profile table
If profiles.is_admin lives on a row the user can update, they can promote themselves. Roles belong in a separate table the user cannot write, checked through a security definer function:
create table public.user_roles (
user_id uuid references auth.users(id) on delete cascade not null,
role app_role not null,
unique (user_id, role)
);Then check the role inside a function rather than referencing the table directly in every policy — that also avoids recursive policy evaluation.
5. Admin credentials used for ordinary reads
A service-role client bypasses RLS entirely. Using it because "the query kept failing" converts a policy bug into a total bypass — and if that client is imported anywhere reachable from the browser bundle, the key leaks too. Reserve it for genuinely privileged server work, after you've verified who is calling.
6. Sensitive columns exposed on an otherwise fine table
RLS filters rows, not columns. A public profiles table with an email or stripe_customer_id column hands those out to everyone allowed to read the row. Split private fields into a separate table, or expose a view containing only the public columns.
7. Client-side checks doing the real work
Hiding the admin button, redirecting in a useEffect, or reading a flag from localStorage is presentation, not security. The API is directly callable. Every rule you care about must be enforced in a policy or on the server.
How to test your policies in two minutes
- Sign out completely, then call your API for a table that should be private. You should get zero rows.
- Sign in as user A and try to read, update, and delete a row belonging to user B. All three should fail.
- Try to insert a row with someone else's user id in the owner column. It should be rejected.
- List your tables and confirm RLS is enabled on every one — including the ones you think nobody uses.
Check your database exposure
VibeCoden't flags publicly readable tables, missing policies, admin clients reachable from the browser, and leaked keys — with a ready-made fix prompt for your AI agent.
Run a free scanRelated: exposed API keys in frontend code.