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.

Report pages link to a specific rule by id. Since a dot-separated id like 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.
SeverityMeaning
criticalDirectly readable or writable customer data across tenants, or a leaked key.
highA 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

  1. Service-role key shipped to the browser

    criticalblocks pr
    supabase.service-role-key-exposed-to-client

    The 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

  2. Object access via service-role client without tenant scope

    critical
    supabase.service-role-object-access-without-tenant-scope

    A 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);
    }
    Fix
    export 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

  3. Tenant scope taken from the request

    critical
    supabase.user-controlled-tenant-scope

    The 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);
    Fix
    const { 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

  4. Service-role query in an unauthenticated handler

    critical
    supabase.service-role-query-without-authentication

    The 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);
    }
    Fix
    export 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

  5. Request body written to a table without an allow-list

    high
    supabase.mass-assignment-from-request-body

    insert/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);
    Fix
    const { 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

  6. Authorization based on user-editable metadata

    high
    supabase.role-check-from-user-metadata

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

  7. Table queried by a user-facing client has RLS disabled

    high
    supabase.table-without-rls

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

    Vulnerable
    create table invoices (
      id uuid primary key,
      tenant_id uuid not null,
      amount numeric not null
    );
    -- no ALTER TABLE ... ENABLE ROW LEVEL SECURITY anywhere
    Fix
    alter 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

  8. RLS policy grants rows without a caller predicate

    high
    supabase.rls-policy-without-caller-predicate

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

    Vulnerable
    create policy "read invoices" on invoices
      for select using (true);
    Fix
    create 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

  9. Server authorizes on a session it never verified

    high
    supabase.server-trusts-unverified-session

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

    Vulnerable
    const { data: { session } } = await supabase.auth.getSession();
    await admin().from("orders").select("*").eq("user_id", session.user.id);
    Fix
    const { 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

  10. Storage object access via service-role client without owner scope

    critical
    supabase.storage-object-access-without-owner-scope

    A 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.)

    Vulnerable
    const { 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

  11. Storage policy without an owner check

    high
    supabase.storage-policy-without-owner-check

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

    Vulnerable
    create policy "read documents" on storage.objects
      for select to authenticated
      using (bucket_id = 'documents');
    Fix
    create 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

  12. SECURITY DEFINER function without a caller check

    high
    supabase.security-definer-function-without-caller-check

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

    Vulnerable
    create 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
    $$;
    Fix
    create 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

  13. Function builds dynamic SQL from a parameter without quoting

    critical
    supabase.dynamic-sql-from-function-parameter

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

    Vulnerable
    create 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 $$;
    Fix
    create 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

  14. RLS policy reads user_metadata from the JWT

    critical
    supabase.rls-policy-trusts-user-metadata

    A 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).

    Vulnerable
    create policy "admins read all" on public.orders
      for select to authenticated
      using (((select auth.jwt()) -> 'user_metadata' ->> 'is_admin')::boolean);
    Fix
    create 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

  15. Policies exist but RLS is never enabled on the table

    high
    supabase.policies-without-rls-enabled

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

    Vulnerable
    create policy "owner reads" on public.notes
      for select using (owner_id = (select auth.uid()));
    -- and no: alter table public.notes enable row level security;
    Fix
    alter 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

  16. Sign-up trigger copies a role from user metadata into the profile

    critical
    supabase.role-from-signup-metadata

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

    Vulnerable
    create 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 $$;
    Fix
    insert 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 role

    CWE: CWE-269, CWE-915 · confidence 0.85 · fixture: 048-role-from-signup-metadata

  17. Users can rewrite the role column that grants their privileges

    critical
    supabase.self-assignable-role-column

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

    Vulnerable
    create policy "update own" on public.profiles
      for update to authenticated using (id = auth.uid());
    -- no WITH CHECK; is_admin() reads profiles.role
    Fix
    revoke 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

  18. Users can rewrite the credits, balance or plan the server trusts

    high
    supabase.self-writable-entitlement-column

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

    Vulnerable
    create policy "update own" on public.profiles
      for update to authenticated using (id = auth.uid());
    -- no WITH CHECK; the export route refuses when profiles.credits <= 0
    Fix
    revoke 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 function

    CWE: CWE-863, CWE-840 · confidence 0.70 · fixture: 049-self-writable-entitlement-column

  19. View runs with its owner's rights over a protected table

    high
    supabase.view-runs-with-owner-rights

    A 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).

    Vulnerable
    create 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 it
    Fix
    alter 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

  20. Write policy open to anon or every role

    high
    supabase.anon-write-policy

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

    Vulnerable
    create policy "anyone can log events" on public.events
      for all using (true) with check (true);
    Fix
    create 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