The next 60 minutes

Leaking right now? Do these five things, in order.

Calm and specific. Each step has copy-paste SQL for the Supabase SQL editor, says what it changes, and how to undo it. Then start a full check and email the founder directly.

Don’t do these, even though they feel right.

  • Don’t make the repo private and stop there. The leak is in your database, not your repo. It stays open.
  • Don’t rotate the public (anon) key to fix it. It is meant to be public, and the new one ships in your pages too. Access rules are the fix.
  • Don’t delete the table or its data. Your users lose their data and you lose the record of what was exposed.
  1. 010–5 minutes

    Rotate

    If a secret key is exposed, rotate it first.

    A Supabase service role key (or a newer sb_secret_… key) ignores every access rule, so nothing else on this page helps until it is replaced. The same goes for payment and AI keys found in your code or git history. Deleting the key from the code is not enough: old commits keep it (PL-011).

    Supabase

    Project Settings → API Keys. With newer secret keys: create a new one, switch your server to it, delete the old one. With older JWT keys, rotating the JWT secret also changes the public key and signs every user out, so update your app’s environment variables straight after.

    Payments

    Roll the secret key in your payment provider’s dashboard (for Stripe, Developers → API keys). Update the server that uses it.

    AI providers

    Delete the exposed key on the provider’s API keys page and create a new one. Check the usage page for spend you don’t recognise.

    Not sure whether a key is server-only? PL-010 shows how to tell in one command, locally.

  2. 025–15 minutes

    Find

    Find every table outsiders can read.

    Paste this into the SQL editor of your Supabase project. It only reads the catalogue: it changes nothing. Tables with personal columns are listed first; start with those.

    1-find-open-tables.sql · read-only
    -- PatchLabs: which public tables can outsiders read? Read-only.
    with personal as (
      select table_name::text as table_name,
             string_agg(column_name::text, ', ') as personal_columns
      from information_schema.columns
      where table_schema = 'public'
        and column_name ~* '(email|phone|mobile|whatsapp|address|birth|dob|passport|ssn)'
      group by table_name
    ),
    tbl as (
      select c.relname::text as table_name, c.relrowsecurity as rls_on
      from pg_class c
      join pg_namespace n on n.oid = c.relnamespace
      where n.nspname = 'public' and c.relkind = 'r'
    ),
    open_reads as (
      select tablename::text as table_name,
             string_agg(policyname || ' [' || array_to_string(roles, ',') || ']', '; ') as policies
      from pg_policies
      where schemaname = 'public'
        and cmd in ('SELECT', 'ALL')
        and (qual = 'true'
             or qual ilike '%auth.uid() is not null%'
             or qual ilike '%auth.role() = ''authenticated''%')
      group by tablename
    )
    select t.table_name,
           case when not t.rls_on then 'RLS is OFF'
                else 'Open read policy: ' || o.policies end as problem,
           coalesce(p.personal_columns, '-') as personal_columns
    from tbl t
    left join personal p using (table_name)
    left join open_reads o using (table_name)
    where not t.rls_on or o.policies is not null
    order by (p.personal_columns is null), t.table_name;
    RLS is OFF
    No rules at all. If the API roles have access, the public key reads and writes everything (PL-008).
    [anon] or [public]
    Anyone on the internet can read every row (PL-001).
    [authenticated]
    Anyone who signs up can read every row (PL-002).

    Some rows are meant to be public (products, opening hours, a shop’s own phone number). Focus on tables holding your users’ details.

  3. 0315–30 minutes

    Close

    Close the worst table.

    Pick the table with the most sensitive personal columns. First save its current rules, so you can see what you changed and undo it:

    2-save-current-rules.sql · read-only
    -- Replace profiles with your table name. Copy the output somewhere safe.
    select policyname, cmd, roles, qual, with_check
    from pg_policies
    where schemaname = 'public' and tablename = 'profiles';

    Then choose one of two fixes.

    A · Owner-only (keeps the app working)

    Use this when each row belongs to one user, through a column such as user_id (for a profiles table it is usually id). Users keep reading their own rows; nobody else reads anything.

    3a-owner-only.sql
    -- Replace profiles, user_id and the policy name with yours (from step 2).
    alter table public.profiles enable row level security;
    drop policy if exists "Profiles are viewable by everyone" on public.profiles;
    create policy "Owner reads own rows"
      on public.profiles for select
      to authenticated
      using ((select auth.uid()) = user_id);

    B · Emergency lock (fastest)

    Stops every read through the API, including your own app’s, until you add a proper rule. A feature breaks; the leak stops. Use it when you can’t work out the owner column in time.

    3b-emergency-lock.sql
    -- Replace profiles with your table. Drops every read rule on it.
    alter table public.profiles enable row level security;
    do $$
    declare p record;
    begin
      for p in select policyname from pg_policies
               where schemaname = 'public' and tablename = 'profiles'
                 and cmd in ('SELECT', 'ALL')
      loop
        execute format('drop policy %I on public.profiles', p.policyname);
      end loop;
    end $$;

    To undo, re-create the rules you saved above. A lock like this is a stop-gap: replace it with a proper rule (option A, or the full check below) as soon as you can.

    Save it in your repo too. Add the same SQL as a new file in supabase/migrations, so your code matches your database and the next deploy doesn’t quietly reopen the table.

  4. 0430–40 minutes

    Check

    Check it worked, as an outsider would.

    Read the table as a logged-out visitor and as a brand-new user. Both run inside a transaction that is rolled back, so nothing changes.

    4-check-as-outsiders.sql · read-only
    -- Expect 0 from both. Replace profiles with your table.
    begin;
    set local role anon;
    select count(*) as visitor_can_read from public.profiles;
    set local role authenticated;
    select set_config('request.jwt.claims',
      '{"sub":"00000000-0000-0000-0000-000000000000","role":"authenticated"}', true);
    select count(*) as stranger_can_read from public.profiles;
    rollback;

    Then from outside, against your own project, with the public key from your app:

    terminal
    curl "https://<your-project-ref>.supabase.co/rest/v1/profiles?select=*&limit=1" \
      -H "apikey: <your public anon key>"
    # [] or a permission error: closed.  Any row: still open.

    Finally, sign in to your app as a normal user and check the page that uses this table. If it broke, that is expected with option B; option A should keep it working.

  5. 0540–60 minutes

    Record

    Look for access, write it down, decide who to tell.

    • Look at your API logs. In the Supabase dashboard’s logs, filter requests to /rest/v1/<your table> and look for traffic you don’t recognise. How far back you can see depends on your plan.
    • Write down what happened. Which tables and columns were open, since when (your migration history shows when the rule arrived), what you changed and when. You will need this record whatever you decide next.
    • Decide who to tell. If personal data may have been accessed, you may have to report it. Under UK and EU GDPR, a notifiable breach goes to the regulator (the ICO in the UK) within 72 hours of you becoming aware of it, and people at high risk must be told. This is general information, not legal advice; take advice if you are unsure.

Then · full check

Now find everything else, and get a proven fix.

You closed one table. A full check rebuilds your whole database from your repo, reads every table as an anonymous visitor, a signed-in stranger and the owner, scans your code and git history for keys, then writes the fix and proves it.

  1. Prove the repo is yours

    Commit patchlabs-verify.txt with the one-time code we show you. It takes a minute on GitHub.

  2. The lab runs

    Median 3.5 minutes of agent time per app in our lab study (n=20, 9 Nov 2026). We never connect to your live app or database.

  3. Apply the proven fix

    Download the migration, or commit it on GitHub in one click. Your engineer approves; nothing changes until you apply it.

Public repos today; private repos arrive with our GitHub App. Free for your first app.

Email the founder directly

Manraj Singh Dhillon, technical founder, London. You reach the person who built the lab.

manraj@patchlabs.dev

Subject “Leaking now”. Include your repo link, what you have seen, and your check link if you started one. Starting the check and sending its link is the fastest way to get a person looking at your app.

Never send keys, passwords or customer data by email. Your repo link and a description are enough. If you pasted a key anywhere, rotate it.

Look up a finding

  • PL-008 Row level security not enabled
  • PL-001 Anonymous visitor can read personal data
  • PL-002 Any signed-in user can read every row
  • PL-010 Service role key in client code
  • PL-011 Key still in git history

Found a security problem in PatchLabs itself? Email the same address with the subject “Security”.