SAST + DAST scanners · Supabase

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.

RLS Policy AnalyzerRLS Deep ScannerCriticalRLS Misconfiguration

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.

policy on tasks: (whatever the migrations say; the request below is identical in every case) no ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; -- RLS off ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; -- and no CREATE POLICY CREATE POLICY "read" ON tasks FOR SELECT USING (true); CREATE POLICY "own" ON tasks FOR SELECT USING (auth.uid() = user_id); Any browser not your app: curl works too apikey: eyJhbGciOi… the anon key, copied from the public JS bundle + Bearer <User A JWT> PostgREST GET /rest/v1/tasks Postgres runs the query as role anon, RLS applied response: see the table → 200, 6 rows → 200, [] (0 rows) → 200, 6 rows → 200, 2 rows (User A's) tasks 6 rows, 3 users iduser_idtitle 1user APay rent 2user ADentist, Tuesday 3user BShip order #88 4user BCall the bank 5user CTax return 6user CRenew passport every row returned to an anonymous caller no policy allows anything, so nothing is returned a policy exists, and it says yes to everyone only rows whose user_id equals the caller's auth.uid() The key is meant to be public; it only identifies the project. What the request is allowed to see is decided entirely by RLS. RLS off: Postgres filters nothing. Every row of every user, readable and writable with the key from the bundle. RLS on with no policy: the default is deny. Nothing comes back for anonymous callers, and nothing for the app's own users either, so the app is broken rather than safe. USING (true) is a policy in name only. It passes every row to every caller, so RLS is effectively disabled for that operation. auth.uid() is the sub claim of the caller's JWT. With a real policy, the only rows that exist for this request are the ones the caller owns.

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.

supabase/migrations/0001_init.sql CREATE TABLE profiles ( id uuid primary key, email text); CREATE TABLE tasks ( id uuid, user_id uuid, title text); CREATE TABLE orders ( id uuid, user_id uuid, total int); ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY "read" ON tasks FOR SELECT USING (true); every *.sql in the directory, read in order Per table tableRLSpolicies profiles off not checked tasks on 1 USING (true) orders on 0 tables = CREATE TABLE names rls = ALTER TABLE … ENABLE ROW LEVEL SECURITY policies = CREATE POLICY blocks, per table Findings none yet: each check below adds one as the three sets are compared CRITICAL profiles: RLS not enabled anon key reads and writes every row HIGH orders: RLS on, no policies nobody can read it, which is still a bug HIGH tasks: permissive policy USING (true) filters nothing for SELECT The analyzer parses SQL text. It never needs a database connection or the anon key. Three CREATE TABLE statements give the set of tables: profiles, tasks, orders. profiles is not in the RLS set: CRITICAL, and no further checks run on it. orders has RLS but zero CREATE POLICY blocks: HIGH. USING (true) or 1=1 is HIGH; a policy that never mentions auth.uid() is MEDIUM.

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.

From the JS bundles url https://xyz.supabase.co key eyJhbGciOi….eyJpc3Mi….Ab12… .from("profiles") .from("tasks") .rpc("get_stats") + GET /rest/v1/ (OpenAPI) lists every table Request one request per table per tier,one per RPC function, all throughthe rate-limited client GET /rest/v1/profiles ?select=*&limit=1 apikey: <anon> (no Authorization header) Tier 1 · anonymous read POST /rest/v1/profiles {} (a safe, empty body) apikey: <anon> (no Authorization header) Tier 1 · anonymous write GET /rest/v1/tasks ?select=id&limit=1 1st: Bearer <User A JWT> 2nd: Bearer <User B JWT> Tier 3 · cross-user read POST /rest/v1/rpc/get_stats {} (no Authorization header) RPC function, found via .rpc() Supabase REST (PostgREST) responses decide everything;a 4xx or an empty list is nevera finding 200 [{id, email, role}] rows came back: publicly readable email is a sensitive column: CRITICAL otherwise HIGH · values never stored 201 Created a row was written: CRITICAL 400 column constraint cleared RLS, failed the schema: inferred 401 / 403: nothing A: 200 [{id: "a1"}] B: 200 [{id: "a1"}] same id in both lists: CRITICAL B gets [] or a 4xx: nothing 200 {"users": 1532, …} callable with no token: HIGH anything but 200: nothing Findings none yet CRITICAL profiles readable by anyone · columns: id, email, role · 1 row sampled CRITICAL profiles writable with the anon key CRITICAL tasks: User B read User A's rows · overlapping ids: a1 HIGH rpc/get_stats callable without authentication The anon key is itself a JWT (eyJ….eyJ….), found by regex in the bundle. Tables come from .from() calls and the /rest/v1/ OpenAPI spec; functions from .rpc() calls. Rule: status 200 and a body other than []. The finding records column names only; row values are never written to the report. A 400 whose error is a column constraint means the row passed RLS and failed the schema: the table is still writable, reported at lower confidence. Guard: User A must get rows first; User B must get a 200 with a non-empty list; the id sets must overlap. Anything else is not a finding. Every function discovered through .rpc() is posted to /rest/v1/rpc/{name} with no token. A 200 means anyone on the internet can call it.

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.

DAST · RLS Deep Scannerprofiles readable with anon keyGET /rest/v1/profiles → 200, rows SAST · RLS Policy Analyzerprofiles: RLS not enabledsupabase/migrations/0001_init.sql CONFIRMED RLS GAPsame table, both sidesconfidence ↑ · severity boosted SAST ONLYorders: RLS not enabledno .from("orders") in any bundle, so DAST never asked GENERATED DAST TEST · rls_bypassGET /rest/v1/orders?select=*apikey from the repo's SUPABASE_ANON_KEY setting

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

Why it matters

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.

Supabase's own warning

"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.

Clubhouse (2021)

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.

Facebook (2013)

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);

Limits and what each side needs

CheckSideNeedsReports whenSeverity
Missing RLSSASTsupabase/migrations/*.sql in the repoCREATE TABLE with no ENABLE ROW LEVEL SECURITYCritical
No policiesSASTsameRLS enabled, zero CREATE POLICYHigh
Permissive policySASTsameUSING (true), WITH CHECK (true) or 1=1High
Tier 1 anon read / writeDASTSupabase URL + anon key from the bundles200 with rows · 200/201 on insert, or a 400 constraint error (inferred)Critical (High if no sensitive column)
Tier 3 cross-userDASTtwo authenticated sessions (User A, User B)an id User A owns appears in User B's resultCritical
RPCDASTfunction names from .rpc() calls200 from /rest/v1/rpc/{name} with no tokenHigh
What it stays quiet about

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.