Rules
Rule packs supabase-authorization and supabase-storage-rpc, the rulesets behind every scan. 20 rules ship today. Every rule ships with a vulnerable fixture that must fire and a secure twin that must stay silent — source, rules and fixtures on GitHub.
supabase.table-without-rls isn't a safe URL fragment, deep links here use a normalized anchor: drop the supabase. prefix, replace dots with dashes, prepend rule-. So supabase.table-without-rls → #rule-table-without-rls.| Severity | Meaning |
|---|---|
| critical | Directly readable or writable customer data across tenants, or a leaked key. |
| high | A missing or incomplete authorization layer that a further step would exploit. |
Only one rule fails a GitHub Check on its own today — service-role key exposed to the browser, marked blocks pr below. Every other rule reports as likely and does not fail the check by itself: turning a likely finding into a block without reasoning or verification behind it would trade blocking precision for noise, which is the metric this product is built around. See docs/GITHUB-APP.md for the full conclusion logic.
Shipped
Service-role key shipped to the browser
criticalblocks prsupabase.service-role-key-exposed-to-clientThe Supabase service-role key bypasses Row Level Security completely. If it ends up in a NEXT_PUBLIC_ environment variable or inside a client component, it is downloadable from DevTools by every visitor to your site — full read/write access to every table, for anyone.
Vulnerable"use client"; const supabase = createClient( url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, );Fix"use client"; // anon key in the browser; RLS decides what each row is allowed to show const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);CWE: CWE-798, CWE-200 · confidence 1.00 · fixture: 006-service-role-key-in-client
Object access via service-role client without tenant scope
criticalsupabase.service-role-object-access-without-tenant-scopeA row is looked up by an id that came from the request (a URL param, most often), through the service-role client or a direct Drizzle/Prisma connection — both skip RLS entirely. Nothing checks that the row belongs to the caller's tenant or account. This is the textbook IDOR: the user is authenticated, just not authorized for this particular row.
Vulnerable// app/api/invoices/[id]/route.ts export async function GET(req: Request, { params }: { params: { id: string } }) { const supabase = createAdminClient(); // service role, bypasses RLS const { data } = await supabase.from("invoices").select("*").eq("id", params.id).single(); return Response.json(data); }Fixexport async function GET(req: Request, { params }: { params: { id: string } }) { const { supabase, session } = await requireSession(req); // per-request client, RLS applies const { data } = await supabase .from("invoices").select("*") .eq("id", params.id).eq("tenant_id", session.tenantId) .single(); return Response.json(data); }CWE: CWE-639, CWE-284 · confidence 0.85 · fixture: 001-cross-tenant-invoice-read, 009-unprotected-server-action, 010-batch-lookup-by-ids, 011-monorepo-package-client-helper-query, 012-wrapped-action-module-client-helper, 013-drizzle-direct-db-invoice-read, 014-prisma-direct-db-invoice-read
Tenant scope taken from the request
criticalsupabase.user-controlled-tenant-scopeThe query does have a tenant filter, which is why a quick read looks fine — but the tenant id comes from the request itself (a query string, most often) instead of the caller's session. An attacker just passes a different tenant's id and reads that tenant's data.
Vulnerable// GET /api/invoices?tenant=<any-id> const tenant = new URL(req.url).searchParams.get("tenant"); const { data } = await supabaseAdmin.from("invoices").select("*").eq("tenant_id", tenant);Fixconst { data: profile } = await supabaseAdmin .from("profiles").select("tenant_id").eq("id", user.id).single(); const { data } = await supabaseAdmin .from("invoices").select("*").eq("tenant_id", profile.tenant_id);CWE: CWE-639, CWE-566 · confidence 0.85 · fixture: 004-user-controlled-tenant-id
Service-role query in an unauthenticated handler
criticalsupabase.service-role-query-without-authenticationThe handler runs a privileged query — service role or a direct database connection, either way RLS does not apply — and never establishes who is calling. There is no auth.getUser()/getSession() anywhere in it. If this isn't a webhook with its own signature check, anyone on the internet can call it.
Vulnerable// app/api/admin/users/route.ts — no auth check at all export async function GET() { const { data } = await supabaseAdmin.from("users").select("email, tenant_id"); return Response.json(data); }Fixexport async function GET(req: Request) { const { data: { user } } = await supabaseAdmin.auth.getUser(bearerToken(req)); if (!user || user.app_metadata.role !== "admin") { return new Response("Forbidden", { status: 403 }); } const { data } = await supabaseAdmin.from("users").select("email, tenant_id"); return Response.json(data); }CWE: CWE-306, CWE-284 · confidence 0.80 · fixture: 007-admin-route-without-auth
Request body written to a table without an allow-list
highsupabase.mass-assignment-from-request-bodyinsert/update/upsert writes the parsed request body as-is. The row itself may be correctly scoped to the caller, but the caller can still name any column in the payload — role, tenant_id, price — and have it written.
Vulnerable// caller sends { display_name, role: "admin", tenant_id: "<another tenant>" } const body = await req.json(); await supabase.from("profiles").update(body).eq("id", user.id);Fixconst { display_name } = await req.json(); await supabase.from("profiles").update({ display_name }).eq("id", user.id);CWE: CWE-915 · confidence 0.85 · fixture: 008-mass-assignment-profile-update
Authorization based on user-editable metadata
highsupabase.role-check-from-user-metadatauser.user_metadata is writable by the signed-in user themselves through supabase.auth.updateUser(). Any role, plan or permission flag read from it can be self-granted. Roles belong in app_metadata (service-role only to write) or a server-controlled profiles.role column.
Vulnerable// any user can self-promote: supabase.auth.updateUser({ data: { role: "admin" } }) if (user.user_metadata.role === "admin") { // grant access }Fix// app_metadata can only be written with the service-role key, server-side if (user.app_metadata.role === "admin") { // grant access }CWE: CWE-602, CWE-863 · confidence 0.85 · fixture: 005-role-from-user-metadata
Table queried by a user-facing client has RLS disabled
highsupabase.table-without-rlsA table reachable through the anon key or a user-scoped client has no `enable row level security` in its migrations. PostgREST will hand back every row to anyone holding the public anon key, no query needed beyond a REST call.
Vulnerablecreate table invoices ( id uuid primary key, tenant_id uuid not null, amount numeric not null ); -- no ALTER TABLE ... ENABLE ROW LEVEL SECURITY anywhereFixalter table invoices enable row level security; create policy "tenant can read own invoices" on invoices for select using (tenant_id = public.current_tenant_id());CWE: CWE-284, CWE-862 · confidence 0.90 · fixture: 002-rls-disabled-invoices-read
RLS policy grants rows without a caller predicate
highsupabase.rls-policy-without-caller-predicateRLS is on, so a quick check looks safe — but the policy itself is a no-op, most often `using (true)`. It never compares the row to auth.uid() or a tenant column, so it grants every row to every signed-in caller. This is the single most common mistake in AI-generated migrations.
Vulnerablecreate policy "read invoices" on invoices for select using (true);Fixcreate policy "read invoices" on invoices for select using (tenant_id = public.current_tenant_id());CWE: CWE-863, CWE-284 · confidence 0.85 · fixture: 003-rls-policy-using-true
Server authorizes on a session it never verified
highsupabase.server-trusts-unverified-sessionsupabase.auth.getSession() reads the session out of the cookie and does not revalidate it, so on the server its claims are whatever the browser sent. That is harmless while Row Level Security still checks the token, and it decides the answer where it does not: when the identity scopes a service-role query, or when a role claim opens an admin path. Supabase's own guidance is never to trust getSession() in server code.
Vulnerableconst { data: { session } } = await supabase.auth.getSession(); await admin().from("orders").select("*").eq("user_id", session.user.id);Fixconst { data: { user } } = await supabase.auth.getUser(); if (!user) return unauthorized(); await admin().from("orders").select("*").eq("user_id", user.id);CWE: CWE-287, CWE-565 · confidence 0.70 · fixture: 040-unverified-session-on-server
Storage object access via service-role client without owner scope
criticalsupabase.storage-object-access-without-owner-scopeA Storage call — download, upload, update, move, copy, remove, list or createSignedUrl(s) — runs with the service-role key on a path that came from the caller and is never prefixed with or checked against their id, so any signed-in user can reach another user's files. (An unauthenticated handler doing this is reported by service-role-query-without-authentication instead.)
Vulnerableconst { path } = await req.json(); await admin.storage.from("documents").download(path);Fix// namespaced to the caller, validated, or use their own client so storage policies apply const path = user.id + "/" + fileName; await supabase.storage.from("documents").download(path);CWE: CWE-639, CWE-284 · confidence 0.80 · fixture: 015-storage-download-user-supplied-path
Storage policy without an owner check
highsupabase.storage-policy-without-owner-checkA storage.objects RLS policy checks bucket_id only, never the file's owner, so every signed-in user can read, overwrite or delete every file in the bucket. Reported once per policy; not raised for a SELECT policy on a bucket the app declares public, since open read is the point there.
Vulnerablecreate policy "read documents" on storage.objects for select to authenticated using (bucket_id = 'documents');Fixcreate policy "read documents" on storage.objects for select to authenticated using ( bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid())::text );CWE: CWE-863, CWE-284 · confidence 0.80 · fixture: 016-storage-policy-bucket-only
SECURITY DEFINER function without a caller check
highsupabase.security-definer-function-without-caller-checkA SECURITY DEFINER function in the public schema runs with its creator's privileges, so RLS on the tables it touches never applies inside it. If the body doesn't check auth.uid() or auth.jwt(), anyone who can call it through supabase.rpc() reads other users' rows — and Supabase grants execute to anon and authenticated by default. Escalates to critical when anon or PUBLIC can still execute it; trigger functions aren't flagged.
Vulnerablecreate function public.get_document(doc_id uuid) returns setof documents language sql security definer set search_path = '' as $$ select * from public.documents where id = doc_id $$;Fixcreate function public.get_document(doc_id uuid) returns setof documents language sql security definer set search_path = '' as $$ select * from public.documents where id = doc_id and owner_id = (select auth.uid()) $$; -- or: security invoker · or: revoke execute on function public.get_document from public, anon, authenticated;CWE: CWE-862, CWE-250 · confidence 0.75 · fixture: 017-security-definer-rpc-without-caller-check, 018-security-definer-granted-to-anon
Function builds dynamic SQL from a parameter without quoting
criticalsupabase.dynamic-sql-from-function-parameterA PL/pgSQL function in the public schema glues a text parameter into a statement it runs with EXECUTE: with ||, through format('%s'), or by running the parameter whole. Every function is callable through supabase.rpc() by default, so the caller can close the quote and append their own SQL. Critical when the function is SECURITY DEFINER and anon can call it (the SQL runs as the owner and skips RLS), high when only signed-in users can, medium for SECURITY INVOKER, where RLS still applies. Silent when values go through USING, quote_literal / quote_ident, format %L / %I, a cast to a non-text type, or a check against a fixed list, when the parameter only picks a literal branch of CASE, and on an admin-only SQL runner that refuses callers without an admin role before its EXECUTE.
Vulnerablecreate function public.search_orders(p_status text) returns setof public.orders language plpgsql security definer set search_path = '' as $$ begin return query execute 'select * from public.orders where status = ''' || p_status || ''''; end $$;Fixcreate function public.search_orders(p_status text) returns setof public.orders language plpgsql security invoker set search_path = '' as $$ begin return query execute 'select * from public.orders where status = $1' using p_status; end $$; -- dynamic identifiers: format('... %I ...', p_column)CWE: CWE-89 · confidence 0.80 · fixture: 045-dynamic-sql-from-function-parameter
RLS policy reads user_metadata from the JWT
criticalsupabase.rls-policy-trusts-user-metadataA policy decides access from a claim under user_metadata. Any signed-in user writes user_metadata themselves with supabase.auth.updateUser({ data }), and it is signed into their next access token without review, so the policy grants itself. Read from the migrations alone: no query from your app is needed, because PostgREST applies the policy to anyone with the public key. Supabase's own linter reports the same shape (0015).
Vulnerablecreate policy "admins read all" on public.orders for select to authenticated using (((select auth.jwt()) -> 'user_metadata' ->> 'is_admin')::boolean);Fixcreate policy "admins read all" on public.orders for select to authenticated using (((select auth.jwt()) -> 'app_metadata' ->> 'is_admin')::boolean); -- app_metadata is written only with the service role; a roles table joined on auth.uid() works too.CWE: CWE-602, CWE-863 · confidence 0.90 · fixture: 037-policy-trusts-user-metadata
Policies exist but RLS is never enabled on the table
highsupabase.policies-without-rls-enabledA migration writes policies for a table and never runs `enable row level security`, so every one of those policies is dead and the table is fully readable and writable through the Data API. The policies are what make this a defect rather than a choice: they show the table was meant to be private. Supabase's own linter reports it as 0007.
Vulnerablecreate policy "owner reads" on public.notes for select using (owner_id = (select auth.uid())); -- and no: alter table public.notes enable row level security;Fixalter table public.notes enable row level security; create policy "owner reads" on public.notes for select using (owner_id = (select auth.uid()));CWE: CWE-284 · confidence 0.90 · fixture: 038-policies-without-rls-enabled
Sign-up trigger copies a role from user metadata into the profile
criticalsupabase.role-from-signup-metadataA function fired on INSERT into auth.users (usually handle_new_user) fills a column unchanged from new.raw_user_meta_data — directly, through coalesce, a cast, an array or a local variable — and that column decides admin access (a route's role check, an is_admin() SQL helper or a policy). The client writes that metadata in supabase.auth.signUp({ options: { data } }), so anyone signs up with role admin. When a SELECT policy of the table reads the table itself (42P17) and no SECURITY DEFINER function that something calls reads the column, the finding is a candidate with that caveat. The same copy into the tenant column the policies scope by (an organisation_id read from the caller's own row, directly or through a get_my_org_id()-style helper) is reported at high severity: anyone who learns a tenant's id signs up inside it. Silent when the value is chosen from an allow-list (CASE ... IN ... ELSE a default), when a BEFORE INSERT trigger on the table sets the column to a literal, and when the column is an enum with no admin-like label.
Vulnerablecreate function public.handle_new_user() returns trigger language plpgsql security definer set search_path = '' as $$ begin insert into public.profiles (id, role) values (new.id, coalesce(new.raw_user_meta_data ->> 'role', 'member')); return new; end $$;Fixinsert into public.profiles (id, role) values (new.id, case when new.raw_user_meta_data ->> 'role' in ('member', 'viewer') then new.raw_user_meta_data ->> 'role' else 'member' end); -- admins are promoted by the server with the service roleCWE: CWE-269, CWE-915 · confidence 0.85 · fixture: 048-role-from-signup-metadata
Users can rewrite the role column that grants their privileges
criticalsupabase.self-assignable-role-columnA column of the user's own row decides a privilege — an admin check in a route or server action, an is_admin() SQL helper, or a policy that reads it — and a permissive RLS policy lets that user update their own row while its WITH CHECK leaves the column free, no column privilege keeps them out and no BEFORE UPDATE trigger holds it back. Any signed-up user sets it to admin with one PATCH to the Data API. Also reported when the update path is closed but INSERT and DELETE policies on the own row let them delete it and insert it again with no BEFORE INSERT trigger watching. A policy changed by ALTER POLICY is not concluded from, and a live snapshot is not judged, since it carries no triggers. Silent when nothing ever creates the user's row (no function writes the table, no INSERT policy, no server-side insert) and when no package.json depends on Supabase, so the SQL is left over from another stack. When a SELECT policy of the table reads the table itself (Postgres answers with infinite recursion, 42P17), the request fails as the migrations stand, so the live policies must differ: the finding stays, as a candidate with that caveat.
Vulnerablecreate policy "update own" on public.profiles for update to authenticated using (id = auth.uid()); -- no WITH CHECK; is_admin() reads profiles.roleFixrevoke update on public.profiles from authenticated; grant update (full_name, avatar_url) on public.profiles to authenticated; -- or: with check (id = auth.uid() and role = (select role from public.profiles where id = auth.uid()))CWE: CWE-269, CWE-863 · confidence 0.80 · fixture: 047-self-assignable-role-column
Users can rewrite the credits, balance or plan the server trusts
highsupabase.self-writable-entitlement-columnA column of the user's own row holds what they paid for or may spend — credits, a balance, a plan, tier or subscription status, a premium flag, a KYC status — and something on the server decides on it: a route that exits when a value read off the caller's own row falls short (profile.credits <= 0, plan !== 'pro'), or a SQL function or policy that orders it against a value or tests it against a literal (select credits into v ...; if v <= 0, case v_plan when 'free' ...). The same user may rewrite it with S6's write path: a permissive own-row UPDATE policy whose WITH CHECK leaves the column free, with no column privilege and no BEFORE trigger naming it, or delete and re-insert. One PATCH to the Data API gives them credits or a paid plan. A column name alone never reports; equality with a parameter or another column (a search filter) is not a decision. Points, coins and gems are reported at medium. Silent where S6 is (altered policies, live snapshots, rows no one creates, no Supabase dependency); a SELECT policy that reads its own table makes it a candidate.
Vulnerablecreate policy "update own" on public.profiles for update to authenticated using (id = auth.uid()); -- no WITH CHECK; the export route refuses when profiles.credits <= 0Fixrevoke update on public.profiles from authenticated; grant update (full_name, avatar_url) on public.profiles to authenticated; -- credits change only in the payment webhook (service role) or a SECURITY DEFINER functionCWE: CWE-863, CWE-840 · confidence 0.70 · fixture: 049-self-writable-entitlement-column
View runs with its owner's rights over a protected table
highsupabase.view-runs-with-owner-rightsA view created without `security_invoker` runs as its owner, and on Supabase the owner is postgres, whom row level security does not bind. So the policies on the tables the view selects from do not apply inside it, and whoever the grants let select the view reads every row those tables hold. The default grants let both API roles select every view in public. Supabase's linter reports the shape as 0010. A view is often meant to publish exactly a subset, which is why this is a lead: the fact is read from your schema, the intent is yours to state (`publicTables` in audit.config.json when the view is public on purpose).
Vulnerablecreate view public.invoice_totals as select tenant_id, sum(total) as total from public.invoices group by 1; -- invoices has RLS and a per-tenant policy; the view ignores itFixalter view public.invoice_totals set (security_invoker = on); -- or, for a server-only view: revoke select on public.invoice_totals from anon, authenticated;CWE: CWE-284, CWE-862 · confidence 0.80 · fixture: 046-view-runs-with-owner-rights
Write policy open to anon or every role
highsupabase.anon-write-policyAn insert, update or delete policy targets anon (or omits the TO clause, which means PUBLIC in Postgres) and decides with `true`. Anyone holding the public key writes the table straight through PostgREST, without your app. An insert-only policy is how deliberate public forms are written, so that case is reported as medium and asks you to confirm the intent; update, delete and `for all` are reported as high because a stranger can change or destroy rows that belong to your users. An update or delete through the Data API reaches only the rows the same visitor can read: Supabase refuses one without a filter, and a filter makes Postgres apply your SELECT policies too. So the finding says every row when a policy lets them read every row, the rows they can read when a condition decides that, and is only a place to look when no policy lets them read the table at all: nothing changes today, and the first read policy you add opens it.
Vulnerablecreate policy "anyone can log events" on public.events for all using (true) with check (true);Fixcreate policy "owner writes events" on public.events for all to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid())); -- allow anon inserts only on tables designed for it, with a predicate on the row's shape.CWE: CWE-284, CWE-862 · confidence 0.85 · fixture: 039-anon-write-policy