Row Level Security
In a Supabase app the anon key ships inside the frontend JavaScript, so anyone can talk to the database directly. Row Level Security is the only thing standing between that key and every row in every table. Two scanners check it: one reads the migrations, one asks the live database.
Why it matters: the anon key is public
Every Supabase frontend embeds the project URL and the anon key, and PostgREST answers any request that carries that key. The policy on each table decides which rows that request may see. The same request below is answered four different ways.
1The anon key is in the public JavaScript bundle, so any browser, script or curl command can send requests straight to PostgREST with it, bypassing the app entirely. The request is always the same; only the table's policy changes.
2 · RLS OFFA table created without ENABLE ROW LEVEL SECURITY has no filter. The anonymous request gets all six rows, and INSERT, UPDATE and DELETE work the same way.
3 · RLS ON, NO POLICYTurning RLS on with no CREATE POLICY denies everything: the response is []. The data is hidden from attackers and from the app's own users alike.
4 · USING (true)A policy whose condition is always true hands back every row again. RLS is on, the dashboard shows a policy, and the table is still public.
5 · USING (auth.uid() = user_id)Now the request carries User A's JWT as well as the anon key. auth.uid() is read from that JWT, and only the two rows whose user_id matches come back. An anonymous caller matches nothing.
Reading the migrations: RLS Policy Analyzer
The static side never connects to a database. It reads every SQL file under supabase/migrations/, extracts three sets (tables, tables with RLS enabled, policies per table) and compares them.
1Every Supabase .sql migration (under supabase/, or using auth.uid(), anon/authenticated/service_role; Prisma and other migrations/ folders are skipped) is read as text and replayed in file order. The analyzer understands Supabase's policy syntax, including multi-line CREATE POLICY statements and quoted policy names with spaces.
2CREATE TABLE statements give the set of tables. Here: profiles, tasks, orders.
3ALTER TABLE … ENABLE ROW LEVEL SECURITY statements give the RLS set (a later DISABLE ROW LEVEL SECURITY removes the table again, and DROP TABLE / DROP POLICY undo earlier statements). A table missing from it is CRITICAL: the anon key grants read and write access to every row. That table gets no further checks, because policies are irrelevant while RLS is off.
4CREATE POLICY blocks are split and grouped per table. RLS enabled with no policy is HIGH: RLS without policies blocks all access, so the table is unusable rather than protected.
5Each policy's USING and WITH CHECK expressions are inspected. USING (true) or 1=1 is HIGH: it disables RLS for that operation. A policy that only targets service_role is skipped. A policy whose expressions never reference auth.uid() is reported at MEDIUM.
Asking the live database: RLS Deep Scanner
The dynamic side needs two things that endpoint discovery already pulled out of the JavaScript bundles: the Supabase project URL and the anon key. With those it sends real requests to the REST API, one tier at a time, and reports only when data actually comes back.
1 · DISCOVERYEndpoint discovery finds the Supabase project URL in the bundles, and the anon key by its shape: a JWT, eyJ….eyJ….. Table names come from .from("…") calls and from the /rest/v1/ OpenAPI spec, which lists every exposed table; function names come from .rpc("…").
2 · TIER 1 READEach table is queried with the anon key and no user token: select=*&limit=1. A 200 with a non-empty body means the table is publicly readable. If a returned column name looks sensitive (email, phone, password, token, …) the finding is CRITICAL, otherwise HIGH. Only column names go in the report.
3 · TIER 1 WRITEA safe empty row is POSTed with the anon key. A 200 or 201 means a row was created: CRITICAL. A 400 whose error is a column constraint means the insert passed RLS and only the schema stopped it, so it is reported as inferred, at lower confidence.
4 · TIER 3 CROSS-USERWith two sessions, the table is read as User A to collect ids, then as User B. An id that appears in both lists means User B can see User A's row: CRITICAL. If A gets nothing, or B gets a 4xx or an empty list, nothing is reported.
5 · RPCEach discovered function is called at /rest/v1/rpc/{name} with no token. A 200 means it is callable by anyone: HIGH.
Both sides together
A static finding says the migrations never enabled RLS. A dynamic finding says the live table answered an anonymous request. On the same table they confirm each other. And when only the static side knows about a table, it tells the dynamic side where to look.
1Two findings land on the same table from different directions: the deep scanner read profiles anonymously at runtime, and the policy analyzer found no ENABLE ROW LEVEL SECURITY for it in the migrations.
2The cross-referencer pairs them: RLS bypass (DAST) + Missing RLS (SAST) = confirmed RLS gap. Proven in the code and against the live database, the finding gets higher confidence and boosted severity.
3Some static findings have no runtime counterpart because discovery never saw the table: orders appears in the migrations but in no bundle, so no dynamic scanner queried it.
4SAST-guided DAST turns the static finding into a test. The rls_bypass strategy takes the Supabase URL from the discovered endpoints and the anon key from the repository's configuration (NEXT_PUBLIC_SUPABASE_ANON_KEY and similar), then queries /rest/v1/orders?select=* with it. A table missing an RLS policy becomes a live query that would not otherwise have run.
Real-world context
RLS is the only access control between the public internet and a Supabase database. Without it on a table, anyone can read all of its data (SELECT * FROM users via the anon key returns everything), anyone can write to it (INSERT, UPDATE, DELETE all work without authentication), and users can see each other's private data. Using the service-role key server-side does not help: the client-side anon key is still a backdoor into any table where RLS was forgotten.
"If you have RLS disabled on any table, the anon key gives full read/write access to that table." This is the most common security issue in Supabase apps: developers create tables and forget to enable RLS.
Broken access controls, functionally equivalent to missing RLS, allowed bulk scraping of 1.3 million user profiles. In Supabase apps, missing RLS is the most common path to the same outcome: the anon key is public, and without policies the entire database is exposed.
Not Supabase, but the same pattern: a broken access control bug let any user delete any photo by changing the photo id in the Graph API. The server did not verify ownership, which is exactly the check a missing RLS policy leaves out.
Vulnerable vs fixed SQL
The fix is a policy per operation, each one tied to auth.uid(). Deny by default, then allow exactly what the owner may do.
-- BAD: No RLS on a table with sensitive data
CREATE TABLE profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
role TEXT DEFAULT 'user'
);
-- Missing: ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Missing: CREATE POLICY ...
-- BAD: RLS enabled but policy is too permissive
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can read" ON tasks FOR SELECT USING (true);-- GOOD: Enable RLS and create proper policies
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Users can only read their own profile
CREATE POLICY "Users view own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
-- Users can only update their own profile
CREATE POLICY "Users update own profile" ON profiles
FOR UPDATE USING (auth.uid() = id);
-- GOOD: Deny all by default, allow specific operations
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users view own tasks" ON tasks
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users create own tasks" ON tasks
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users update own tasks" ON tasks
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users delete own tasks" ON tasks
FOR DELETE USING (auth.uid() = user_id);Limits and what each side needs
| Check | Side | Needs | Reports when | Severity |
|---|---|---|---|---|
| Missing RLS | SAST | supabase/migrations/*.sql in the repo | CREATE TABLE with no ENABLE ROW LEVEL SECURITY | Critical |
| No policies | SAST | same | RLS enabled, zero CREATE POLICY | High |
| Permissive policy | SAST | same | USING (true), WITH CHECK (true) or 1=1 | High |
| Tier 1 anon read / write | DAST | Supabase URL + anon key from the bundles | 200 with rows · 200/201 on insert, or a 400 constraint error (inferred) | Critical (High if no sensitive column) |
| Tier 3 cross-user | DAST | two authenticated sessions (User A, User B) | an id User A owns appears in User B's result | Critical |
| RPC | DAST | function names from .rpc() calls | 200 from /rest/v1/rpc/{name} with no token | High |
The static analyzer only sees migrations: policies created in the Supabase dashboard and never exported leave it reporting a gap that the live scanner will not reproduce, and the two disagreeing is itself useful. The dynamic scanner skips entirely without a Supabase URL and anon key, skips Tier 3 without two sessions, and only ever tests tables and functions that discovery named. Empty lists and 4xx responses are never findings, so a correctly locked-down project produces nothing from either side.