Supabase RLS: the guide to not getting your database wiped
Row Level Security (RLS) is the only line of defense between any visitor to your site and the raw content of your Supabase database. The anon key is public by design — it's in your JS bundle, anyone can use it from the browser console to talk directly to your database. Without properly configured RLS, your database is as open as if you'd published its credentials.
What RLS actually does
By default, a Postgres table without RLS enabled can be queried by any role that has a GRANT on it — and Supabase grants the anon role broad access to the public schema by default. RLS adds an invisible condition, evaluated by Postgres on every query, that filters which rows a role is allowed to see or modify:
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_read_own_profile" ON profiles
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);Without this policy (or with RLS disabled), a SELECT * FROM profiles query made with the anon key returns every row of every user, not just the logged-in person's.
Trap #1: RLS enabled, no policy at all
An AI scaffold often enables RLS out of good reflex — it's become a known best practice — but without necessarily creating the policies that go with it. Counter-intuitive result: RLS enabled with no SELECT policy doesn't make the table more open, it locks it down completely, including for the app itself. This usually gets noticed fast (the app stops working), so it's not the most dangerous scenario.
The real trap is the opposite: a policy that exists, but is too permissive:
-- Looks like a security policy. Isn't one.
CREATE POLICY "allow_all" ON orders
FOR SELECT
USING (true);USING (true) means "always true, for every row" — the RLS equivalent of having none. An AI generating a full CRUD in one pass often adds this "to make it work", without coming back to tighten it.
Trap #2: unnecessary write policies
This is exactly the vulnerability we found in our own app (detailed here): if every real write in your app goes through the server with a service_role client (which bypasses RLS entirely), then an INSERT or UPDATE policy open to anon serves no legitimate purpose — but stays a wide-open door for anyone else.
Trap #3: policies on other tables via subquery
A subtler case: a policy that checks a condition via a subquery on another table.
CREATE POLICY "read_own_findings" ON findings
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM scans
WHERE scans.id = findings.scan_id AND scans.paid = true
)
);This policy only works if the role running the query also has SELECT rights on scans. If you've revoked direct access to scans for anon (for example to hide certain columns), the subquery fails silently and the policy behaves as if it were always false — breaking legitimate access rather than leaking data, but a production bug that's hard to diagnose if you're not expecting it.
RLS checklist before going to production
- Is RLS enabled on every table in the public schema, including recently created ones? (The Supabase dashboard flags tables without RLS with a warning.)
- Does any policy contain
USING (true)orWITH CHECK (true)without that being a deliberate choice (an intentionally public table)? - Do the writes that matter (payment, role, admin status) go through a server-side
service_roleclient? If so, are the write policies open toanonactually necessary? - Have you tested a real query with just the anon key (as an attacker would), not just confirmed the app works while logged in?
That last point is the one most developers skip — it's exactly what a Vetora scan automates: it extracts the real table names called by your JS and queries them with the public anon key, to see what actually responds.