Rules
Rule packs supabase-authorization and supabase-storage-rpc, the rulesets behind every scan. 14 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
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
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
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.
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