Sample report

A sample code audit of a Supabase app

This is the report our code audit delivers, written for an app we built for the purpose. Acme is a small client portal for design studios: projects, messages, files and invoices, shared with each client. We built it with React, Vite and Supabase in the layout Lovable generates, with the problems our security checklist for Lovable apps covers, plus a few about tests, backups and load.

A sample on a demo app, with no client data

Pulore built Acme and attacked it itself. Every person, studio and invoice in it is made up, and every address is on example.com. The attacks ran on a local copy on our own machine.

Review by Sachin Mour

Summary

Acme works, and much of its data is open to anyone. Signed out, with only the public key in the app's JavaScript, anyone can read every studio's invoices, messages and client list, and download the invoice PDFs. The JavaScript also carries Supabase's secret key, which skips every access rule and can list or delete every account.

Twelve problems need fixing before the app has real clients, in about 28 hours of code changes. We wrote each fix, applied it to a local copy of the app and reran every attack. All were refused, and the app still worked for each kind of user.

Five more are worth fixing this quarter, in about 17 hours. Nothing tests the access rules, no backup has been restored, a studio's projects can point at another studio's clients, the dashboard repeats 31 requests every 10 seconds, and some policies will slow down as tables grow. Four things a scanner flags can stay as they are.

  • 12to fix now, about 28 hours
  • 5to fix this quarter, about 17 hours
  • 4to leave alone
  • 16attacks reproduced, then refused after the fixes

Hours are estimated code changes on this demo. They exclude moving the hosted project to new keys, setting up an email sender, QA and release.

Scope and method

One product: the Acme web app (React, Vite and TypeScript), its Supabase database (6 migrations with 9 tables, a view and 5 functions), one storage bucket, two Edge Functions and the auth settings.

  1. Read the code

    Every migration, policy and function, then the app's code, starting from the paths that carry sign-in, invoices and files.

  2. Run the checks

    The 13 checks from our security checklist for Lovable apps, with its SQL, plus Supabase's own tools: supabase db lint and the Security Advisor's lints.

  3. Attack a local copy

    With curl, signed out with only the public key, and signed in with real passwords as six test users: two studio owners, a member of staff, two clients and Acme's own admin. Keys, tokens and the local stack's internal address are replaced by labels in the evidence.

  4. Fix, rerun, revert

    Each fix was applied to the vulnerable copy, then every attack was rerun, along with 17 checks that real users can still do their jobs and 37 database tests. Then we went back to the vulnerable copy and reran the attacks, which all worked again. Each database fix was also tested on its own.

Run on November 6, 2026, on Supabase CLI 2.31.4 and Postgres 17.4. Every version is listed at the end.

Ranked findings

Each finding has a priority, the evidence from the code, what it exposes, the fix and an estimate of the hours of code changes it takes.

  1. 1The secret key ships in the app's JavaScriptFix now4 h
  2. 2Anyone can read every studio's invoicesFix now1 h
  3. 3Invoice PDFs are public, listable and writableFix now4 h
  4. 4The messages table has row level security offFix now2 h
  5. 5Any user can join any studio, as its ownerFix now4 h
  6. 6Users can make themselves admins, and owners can pick their own planFix now2 h
  7. 7A view hands every studio's client list to anyoneFix now1 h
  8. 8The invoice link function opens any invoiceFix now2 h
  9. 9A database function gives out the PDF service's API keyFix now3 h
  10. 10Clients can see the studio's other clientsFix now1 h
  11. 11Removing a client doesn't remove their accessFix now2 h
  12. 12Anyone can sign up with an address they don't own and the password 123456Fix now2 h
  13. 13No tests, and nothing runs themFix this quarter8 h
  14. 14No restore has been tried, and nothing copies the PDFsFix this quarter3 h
  15. 15Projects and invoices can point at another studio's clientsFix this quarter2 h
  16. 16The dashboard sends 31 requests every 10 secondsFix this quarter2 h
  17. 17Policies run auth.uid() once per row, and foreign keys have no indexesFix this quarter2 h
  18. 18The public key is written into the codeLeave alone0 h
  19. 19Anyone can read the plans tableLeave alone0 h
  20. 20The health function skips the JWT checkLeave alone0 h
  21. 21Four other SECURITY DEFINER functionsLeave alone0 h

Fix now (12)

Each of these lets someone reach data or powers they shouldn't. We applied each fix below to a local copy and reran every attack.

Fix nowSecurityChecklist item 8About 4 hours

1.The secret key ships in the app's JavaScript

Where
src/lib/supabaseAdmin.ts:7 builds an admin client from VITE_SUPABASE_SERVICE_ROLE_KEY, which .env:4 sets. Vite builds every VITE_ value into the files it serves. The admin page, src/pages/Admin.tsx:14, uses it to list users.
Evidence
Every key in the built JavaScript: found by grep, decoded by the reproduction script
Found with: grep -ohE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' dist/assets/*.js | sort -u
Then, for each key: the file it's in, and its payload, base64-decoded:
index-BncN2ysr.js: {"iss":"supabase-demo","role":"anon","exp":1983812996}
index-BncN2ysr.js: {"iss":"supabase-demo","role":"service_role","exp":1983812996}
With the service-role key from the bundle: every account (the count and the first of three shown)
$ curl -X GET 'http://127.0.0.1:55321/auth/v1/admin/users?per_page=100' \
  -H 'apikey: <service-role key>' \
  -H 'Authorization: Bearer <service-role key>'
HTTP 200
{
  "total": 7,
  "first_three": [
    {
      "email": "[email protected]",
      ...
What an attacker gets
Everything. The service-role key skips row level security on every table and works the admin API. With it we listed all 7 accounts and read the PDF service's key, which no user can see. The same key can delete accounts and rewrite any table. We didn't make those calls.
The fix
Move the admin page's calls into an Edge Function that checks the caller is an admin before it uses the secret key. Delete src/lib/supabaseAdmin.ts and the .env line, and rebuild. Then retire the leaked key, as Supabase's rotation guide sets out. Move the browser to a new publishable key and the functions to a new secret key. Deactivate the legacy keys, which retires the old anon key too. Then revoke the legacy JWT secret. The functions read the new keys from SUPABASE_SECRET_KEYS and SUPABASE_PUBLISHABLE_KEYS, and fall back to the legacy variables, so they work on both sides of the switch.
supabase/functions/_shared/keys.ts and admin-users/index.ts (excerpts)
// _shared/keys.ts
function keyFrom(variable: string, legacy: string): string {
  const keys = Deno.env.get(variable);
  if (keys) {
    return (JSON.parse(keys) as Record<string, string>)["default"];
  }
  return Deno.env.get(legacy)!;
}

export const secretKey = () => keyFrom("SUPABASE_SECRET_KEYS", "SUPABASE_SERVICE_ROLE_KEY");

// admin-users/index.ts
const { data: isAdmin, error } = await asCaller.rpc("is_admin");
if (error || isAdmin !== true) {
  return json({ error: "Admins only" }, 403);
}

const admin = createClient(SUPABASE_URL, secretKey());
After the fix
The rebuilt bundle holds only the anon key, and a new CI check fails any build with a secret key in it: it fails today's build and passes the fixed one. The admin page still lists users for an admin, and the function refuses a client with HTTP 403. The move to the new keys happens on the hosted project, so it wasn't tested here.
Fix nowSecurityChecklist item 2About 1 hour

2.Anyone can read every studio's invoices

Where
supabase/migrations/20260925164552_4c3229df….sql:2 adds a select policy named "Enable read access for all users" with using (true), so that clients could see their invoices. Policies combine with OR, so it opens the table to everyone, signed out included.
Evidence
Signed out, with only the public key. Content-Range counts 68 rows.
$ curl -X GET 'http://127.0.0.1:55321/rest/v1/invoices?select=number,status,amount_cents,workspace_id&order=workspace_id,number&limit=4' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Prefer: count=exact'
HTTP 206
Content-Range: 0-3/68
[
  {
    "number": "INV-2026-0001",
    "status": "paid",
    "amount_cents": 522500,
    "workspace_id": "a1de5000-0000-4000-8000-000000000001"
  },
  ...
What an attacker gets
Signed out: all 68 invoices from both studios, with numbers, amounts, status and the client each one is for. Signed in as Ana, a client of the other studio, we read all 60 of Alder Studio's.
The fix
Replace the policy with one that lets clients read their own invoices, in one transaction. Studio staff keep their existing policy. Adding an owner policy beside the old one wouldn't help: the old one still lets everyone through.
fixes/sql/02-invoices-policy.sql
begin;

drop policy "Enable read access for all users" on public.invoices;

create policy "Clients read their own invoices"
on public.invoices for select
to authenticated
using (
  client_id in (
    select c.id from public.clients c
    where c.user_id = (select auth.uid())
  )
);

-- Signed-out visitors never need invoices.
revoke all on table public.invoices from anon;

commit;
After the fix
Signed out: permission denied. Ana reads none of Alder's invoices. Sam, Alder's client, still reads his own 20, and Maya, Alder's owner, all 60.
Fix nowSecurityChecklist item 7About 4 hours

3.Invoice PDFs are public, listable and writable

Where
supabase/migrations/20260916143005_72796a33….sql:27 makes the invoices bucket public, and lines 29 to 41 add read, upload and update policies that check only the bucket's name. src/lib/pdf.ts:48 links each PDF by its public URL.
Evidence
Signed out: list a studio's files (the count and the first three shown)
$ curl -X POST 'http://127.0.0.1:55321/storage/v1/object/list/invoices' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Content-Type: application/json' \
  -d '{"prefix": "a1de5000-0000-4000-8000-000000000001", "limit": 100}'
HTTP 200
{
  "files": 60,
  "first_three": [
    "INV-2026-0001.pdf",
    "INV-2026-0002.pdf",
    "INV-2026-0003.pdf"
  ]
}
As Ana, a client of another studio: overwrite one (the file's path shortened). Then, signed out, download it
$ curl -X POST 'http://127.0.0.1:55321/storage/v1/object/invoices/a1de5000-0000-4000-8000-000000000001/INV-2026-0003.pdf' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Ana's access token>' \
  -H 'Content-Type: application/pdf' \
  -H 'x-upsert: true' \
  --data-binary '@replacement.pdf'
HTTP 200
...

$ curl -X GET 'http://127.0.0.1:55321/storage/v1/object/public/invoices/a1de5000-0000-4000-8000-000000000001/INV-2026-0003.pdf'
HTTP 200
(a PDF, 887 bytes. Its text lines:)
  Alder Studio
  Invoice INV-2026-0003
  Billed to: Brightline Coffee, Sam Rivera <[email protected]>
  Amount due: REPLACED BY ANA, A CLIENT OF ANOTHER STUDIO
  ...
What an attacker gets
Signed out, a list of every PDF in a studio's folder and a download of each, with no key at all. Any signed-in user can also replace a studio's invoice, and its client then downloads the replacement from the studio's own portal.
The fix
Make the bucket private, take only PDFs up to 10 MB, and replace the three bucket-wide policies, in one transaction, with policies that give each file to its studio's staff and its own client. Anyone can create a studio and be its staff and its client, so a client's file must be named by one of their invoices in the same studio, and sit in that studio's folder. A constraint keeps every invoice's path in its own studio's folder too. Switch the portal's download links to signed URLs, which expire.
fixes/sql/03-invoice-bucket.sql (excerpt)
update storage.buckets
set public = false,
    allowed_mime_types = array['application/pdf'],
    file_size_limit = 10485760
where id = 'invoices';

drop policy "Invoice files are publicly accessible" on storage.objects;
drop policy "Authenticated users can upload invoice files" on storage.objects;
drop policy "Authenticated users can update invoice files" on storage.objects;

-- A client reads a file only through one of their own invoices that names it,
-- where the invoice, the client and the file's folder all belong to the same
-- studio. Anyone can create a studio and be its staff and its client, so an
-- invoice they write must not reach another studio's files.
create policy "Clients read their own invoice files"
on storage.objects for select
to authenticated
using (
  bucket_id = 'invoices'
  and exists (
    select 1
    from public.invoices i
    join public.clients c on c.id = i.client_id
    where i.pdf_path = storage.objects.name
      and c.workspace_id = i.workspace_id
      and (storage.foldername(storage.objects.name))[1] = i.workspace_id::text
      and c.user_id = (select auth.uid())
  )
);

-- And at the source: an invoice's file is always its own studio's, under its
-- own number, which is the path the app writes.
alter table public.invoices
  add constraint invoices_pdf_path_matches_invoice
  check (pdf_path is null or pdf_path = workspace_id::text || '/' || number || '.pdf');

-- Plus: staff read, upload and replace their own studio's files.
After the fix
Signed out, the list is empty and the public URL fails. Ana's overwrite is refused: the storage API answers HTTP 400 with "new row violates row-level security policy". Two crafted invoices are refused too, with HTTP 400: one Sam writes in a studio of his own naming Priya's file, and one Tom writes for his client Ana naming Sam's. With the path constraint dropped, the policy alone still keeps both files out of reach. Sam still downloads his own PDF through a signed URL, and Leo, Alder's staff, still uploads.
Fix nowSecurityChecklist item 1About 2 hours

4.The messages table has row level security off

Where
supabase/migrations/20260921090418_583fab7a….sql:2 creates public.messages and never enables row level security. The table still has the API's default grants, so anyone with the public key can reach it.
Evidence
Signed out: read the messages, then post one as Alder's owner
$ curl -X GET 'http://127.0.0.1:55321/rest/v1/messages?select=body,created_at,workspace_id&order=created_at&limit=3' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Prefer: count=exact'
HTTP 206
Content-Range: 0-2/44
[
  {
    "body": "First round of concepts is up in the files tab. Three directions, notes on each.",
    "created_at": "2026-01-15T09:00:00+00:00",
    "workspace_id": "a1de5000-0000-4000-8000-000000000001"
  },
  ...

$ curl -X POST 'http://127.0.0.1:55321/rest/v1/messages?select=id,sender_id,body' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=representation' \
  -d '{"workspace_id": "a1de5000-0000-4000-8000-000000000001", "project_id": "587266a9-2899-b979-48d2-e53e8f63e615", "sender_id": "11111111-1111-4111-8111-111111111111", "body": "Posted signed out by the audit script."}'
HTTP 201
...
What an attacker gets
Signed out: all 44 messages between the studios and their clients, plus the power to post as anyone, a studio's owner included, and to delete any message.
The fix
Turn on row level security and add the policies in the same change: staff read their studio's messages, clients read the ones on their projects, and everyone posts only under their own ID, on a project they can see.
fixes/sql/04-messages-rls.sql (excerpt)
alter table public.messages enable row level security;
revoke all on table public.messages from anon;

-- Posts go out under the sender's own ID, on a project they can see, with the
-- project's real studio ID.
create policy "Members post as themselves on their projects"
on public.messages for insert
to authenticated
with check (
  sender_id = (select auth.uid())
  and exists (
    select 1 from public.projects p
    where p.id = messages.project_id
      and p.workspace_id = messages.workspace_id
      and (
        public.is_workspace_staff(p.workspace_id)
        or exists (
          select 1 from public.clients c
          where c.id = p.client_id and c.user_id = (select auth.uid())
        )
      )
  )
);

-- Plus two select policies: staff, and clients on their own projects.
After the fix
Signed out: permission denied. Sam still reads the 4 messages on his project and posts as himself, but not as Maya.
Fix nowSecurityChecklist item 3About 4 hours

5.Any user can join any studio, as its owner

Where
supabase/migrations/20260914101233_6410e044….sql:135 lets signed-in users insert a membership as long as it's their own: with check (auth.uid() = user_id). It doesn't ask which studio, or who chose the role. src/pages/JoinWorkspace.tsx:23 takes the role from the invite link's query string.
Evidence
As Ana, a client of Juniper Dev: add herself to Alder Studio, then list its projects
$ curl -X POST 'http://127.0.0.1:55321/rest/v1/workspace_members' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Ana's access token>' \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=minimal' \
  -d '{"workspace_id": "a1de5000-0000-4000-8000-000000000001", "user_id": "66666666-6666-4666-8666-666666666666", "role": "owner"}'
HTTP 201

$ curl -X GET 'http://127.0.0.1:55321/rest/v1/projects?select=name&workspace_id=eq.a1de5000-0000-4000-8000-000000000001&limit=3' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Ana's access token>' \
  -H 'Prefer: count=exact'
HTTP 206
Content-Range: 0-2/30
...
What an attacker gets
Any account holder can add themselves to any studio with any role, then read and change everything in it. For Alder, that's 30 projects and 3 clients with their emails. A studio's ID is in every invite link it has sent, and findings 2 and 7 hand it to anyone. With sign-up open (finding 12), any account holder means anyone.
The fix
Memberships come from invites. Staff create an invite for an address, and a function accepts it once, before it expires, for that address, with the role the invite sets. The browser sets only the studio, the address and the role: the token, expiry and creator come from the database. A client invited as staff becomes staff. A trigger adds the owner when a studio is created, so the open insert policy can go. The address check proves nothing until sign-up confirms addresses, so finding 12 ships before invites do.
fixes/sql/05-invites.sql (excerpt)
drop policy "Users can join workspaces" on public.workspace_members;

-- Explicit grants, not the API's old defaults: since October 30, 2026, hosted
-- projects don't expose new tables on their own. The browser sets only the
-- studio, the address and the role. The token, expiry, creator and acceptance
-- time come from the database.
revoke all on table public.workspace_invites from anon, authenticated;
grant select, delete on table public.workspace_invites to authenticated;
grant insert (workspace_id, email, role) on table public.workspace_invites to authenticated;

create function public.accept_invite(invite_token text)
returns uuid
language plpgsql
security definer
set search_path = ''
as $$
declare
  invite public.workspace_invites%rowtype;
begin
  select * into invite
  from public.workspace_invites
  where token = invite_token
    and accepted_at is null
    and expires_at > now()
  for update;

  if not found then
    raise exception 'invite not found or expired' using errcode = 'P0002';
  end if;

  if lower(invite.email) <> lower((select u.email from auth.users u where u.id = auth.uid())) then
    raise exception 'this invite is for another address' using errcode = '42501';
  end if;

  -- A new member gets the invite's role. An existing client invited as staff
  -- becomes staff. Nobody is moved down, and an owner stays an owner.
  insert into public.workspace_members as m (workspace_id, user_id, role)
  values (invite.workspace_id, auth.uid(), invite.role)
  on conflict (workspace_id, user_id) do update
    set role = excluded.role
    where m.role = 'client' and excluded.role = 'staff';

  update public.workspace_invites set accepted_at = now() where id = invite.id;

  return invite.workspace_id;
end;
$$;
After the fix
Ana's insert is refused with HTTP 403. With an invite from Maya, Ana joins Alder as a client, and the invite works once. Setting an invite's token, expiry or creator from the browser is refused with HTTP 403. Sam, a client, invited as staff becomes staff. A new studio still gets its owner.
Fix nowSecurityChecklist item 4About 2 hours

6.Users can make themselves admins, and owners can pick their own plan

Where
supabase/migrations/20260914101233_6410e044….sql:112 lets users update their own profile, every column of it. supabase/migrations/20261008152210_d5ff47f7….sql:28 adds role to that table, and is_admin() and four admin policies trust it. src/pages/Settings.tsx:20 saves the whole profile object, and line 27 sets the plan from the browser.
Evidence
As Sam, a client of Alder: make himself an admin. The clients he can see go from 1 to 5, across both studios.
$ curl -X PATCH 'http://127.0.0.1:55321/rest/v1/profiles?id=eq.33333333-3333-4333-8333-333333333333&select=id,email,role' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Sam's access token>' \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=representation' \
  -d '{"role": "admin"}'
HTTP 200
Content-Range: 0-0/*
[
  {
    "id": "33333333-3333-4333-8333-333333333333",
    "email": "[email protected]",
    "role": "admin"
  }
]
What an attacker gets
Any user can become an Acme admin with one request, and read every studio's clients, projects and profiles. Any studio owner can move to the top plan without paying. A with check that only re-tests ownership wouldn't help, because the row is still the user's own. Row level security picks rows, not columns, so the clean fix is to say which columns the browser may write.
The fix
Limit which columns the browser can write: name and avatar on profiles, name and slug on studios. The plan can then change only on the server, for example from your billing provider's webhook, which Acme doesn't have yet. The settings page sends just the two fields.
fixes/sql/06-role-and-plan-columns.sql
begin;

revoke insert, update on table public.profiles from anon, authenticated;
grant insert (id, full_name, avatar_url) on table public.profiles to authenticated;
grant update (full_name, avatar_url) on table public.profiles to authenticated;

-- The plan changes only from server-side billing code, with the secret key.
revoke insert, update on table public.workspaces from anon, authenticated;
grant insert (name, slug, owner_id) on table public.workspaces to authenticated;
grant update (name, slug) on table public.workspaces to authenticated;

commit;
After the fix
Sam's role change and Tom's plan change are refused with HTTP 403 ("permission denied"). Sam can still change his name.
Fix nowSecurityChecklist item 5About 1 hour

7.A view hands every studio's client list to anyone

Where
supabase/migrations/20260925164552_4c3229df….sql:7 creates client_overview for the dashboard. A view runs as its owner, here postgres, which skips the row level security on clients.
Evidence
Signed out: the clients table, then the view built on it
$ curl -X GET 'http://127.0.0.1:55321/rest/v1/clients?select=company,email' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>'
HTTP 200
[]

$ curl -X GET 'http://127.0.0.1:55321/rest/v1/client_overview?select=company,name,email,open_invoices,amount_due_cents,workspace_id&order=company' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>'
HTTP 200
Content-Range: 0-4/*
[
  {
    "company": "Brightline Coffee",
    "name": "Sam Rivera",
    "email": "[email protected]",
    "open_invoices": 5,
    "amount_due_cents": 1550000,
    "workspace_id": "a1de5000-0000-4000-8000-000000000001"
  },
  ...
What an attacker gets
Signed out: every studio's clients, with names, companies, emails and what each one owes.
The fix
Make the view run as the person asking. Postgres 15 and later support this.
fixes/sql/07-client-overview-view.sql
alter view public.client_overview set (security_invoker = true);
revoke all on table public.client_overview from anon;
After the fix
Signed out: permission denied. Maya sees her studio's 3 clients, and Sam only his own row.
Fix nowSecurityChecklist item 9About 2 hours

8.The invoice link function opens any invoice

Where
supabase/config.toml:70 turns off the JWT check for invoice-pdf. supabase/functions/invoice-pdf/index.ts:24 then queries with the service-role key, by the studio and invoice number in the request, and never asks who's calling.
Evidence
Signed out: open one of Alder's invoices, then count up through the numbers
$ curl -X POST 'http://127.0.0.1:55321/functions/v1/invoice-pdf' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Content-Type: application/json' \
  -d '{"workspace": "alder", "number": "INV-2026-0007"}'
HTTP 200
{
  "studio": "Alder Studio",
  "invoice": {
    ...
    "number": "INV-2026-0007",
    ...
    "clients": {
      "name": "Sam Rivera",
      "email": "[email protected]",
      "company": "Brightline Coffee"
    }
  },
  "pdfUrl": "<internal API URL>/storage/v1/object/sign/invoices/a1de5000-0000-4000-8000-000000000001/INV-2026-0007.pdf?token=<jwt>"
}

Signed out: walk the invoice numbers INV-2026-0001 to INV-2026-0060
Opened 60 of 60 invoices, naming 3 distinct client email addresses.
What an attacker gets
Signed out: every invoice and its PDF, by counting up from INV-2026-0001. Studio slugs are in every invoice link. Fixing findings 2 and 3 alone would leave this open.
The fix
Require a signed-in user, and run the function's queries as the caller instead of with the service-role key, so row level security decides what comes back. Return the PDF's path rather than a link, and let the page sign it with the user's own session, so the link points at the project's public address. The page behind the email link asks the client to sign in first.
supabase/functions/invoice-pdf/index.ts and src/pages/InvoiceView.tsx (excerpts)
// invoice-pdf/index.ts
const supabase = createClient(SUPABASE_URL, publishableKey(), {
  global: { headers: { Authorization: authHeader } },
});

const {
  data: { user },
} = await supabase.auth.getUser(authHeader.replace("Bearer ", ""));
if (!user) {
  return json({ error: "Sign in to open this invoice" }, 401);
}
// ...
return json({ studio: invoice.workspaces.name, invoice });

// src/pages/InvoiceView.tsx
const pdfUrl = data.invoice.pdf_path ? await signedInvoiceUrl(data.invoice.pdf_path) : null;
After the fix
Signed out: HTTP 401. Signed in, Sam opens his own invoice, and Priya's is a 404. We ran the page's own calls and downloaded the link it builds: it points at the public API address and serves his PDF, HTTP 200.
Fix nowSecurityChecklist item 6About 3 hours

9.A database function gives out the PDF service's API key

Where
supabase/migrations/20261002111840_f8265879….sql:11 creates get_pdf_settings() as SECURITY DEFINER, so it reads app_settings past row level security, and every role can call it. src/lib/pdf.ts:17 calls it from the browser to render invoice PDFs.
Evidence
Signed out: the table, then the function
$ curl -X GET 'http://127.0.0.1:55321/rest/v1/app_settings?select=key,value' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>'
HTTP 200
[]

$ curl -X POST 'http://127.0.0.1:55321/rest/v1/rpc/get_pdf_settings' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Content-Type: application/json' \
  -d '{}'
HTTP 200
{
  "api_key": "pdfr_live_3a9f<rest of the PDF service key>",
  "template_id": "tmpl_invoice_v2"
}
What an attacker gets
Signed out, a caller could read the demo's synthetic PDF API key. In a real deployment, this design would expose a live provider credential and could allow use at the owner's expense. No external provider was contacted here.
The fix
Render PDFs in an Edge Function that keeps the key in its secrets. Close the function to the browser's roles and delete the key from the table. The key has been public, so rotate it with the PDF service.
fixes/sql/09-pdf-settings-function.sql
revoke execute on function public.get_pdf_settings() from public, anon, authenticated;
delete from public.app_settings where key = 'pdf_api_key';
After the fix
Signed out: permission denied. The new render-invoice function refuses clients with HTTP 403. Staff authorization passed; rendering was not tested, because the PDF service is made up.
Fix nowSecurityChecklist item 3About 1 hour

10.Clients can see the studio's other clients

Where
supabase/migrations/20260914101233_6410e044….sql:96 lets every member of a studio read the profile of every other member, and supabase/migrations/20260914101233_6410e044….sql:131 their memberships. Clients are members too, so each client reads the others.
Evidence
As Sam, a client of Alder: the profiles he can read (names and emails shown)
$ curl -X GET 'http://127.0.0.1:55321/rest/v1/profiles?select=id,full_name,email&order=email' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Sam's access token>'
HTTP 200
Content-Range: 0-3/*
[
  {
    "full_name": "Leo Park",
    "email": "[email protected]"
  },
  {
    "full_name": "Maya Chen",
    "email": "[email protected]"
  },
  {
    "full_name": "Priya Shah",
    "email": "[email protected]"
  },
  ...
What an attacker gets
Any client reads the names and email addresses of the studio's other clients, and their memberships. At Alder, Sam from Brightline Coffee reads Priya's, from Kettle & Co. A client portal shouldn't show one client to another.
The fix
Staff read every membership and profile in their studio. A client reads their own, plus the studio's staff, so they know who they're working with. Profiles follow the memberships the caller can see.
fixes/sql/10-client-visibility.sql (excerpt)
drop policy "Members can view workspace members" on public.workspace_members;

create policy "Staff read their studio's memberships"
on public.workspace_members for select
to authenticated
using ( public.is_workspace_staff(workspace_id) );

create policy "Members read their own membership and their studio's staff"
on public.workspace_members for select
to authenticated
using (
  user_id = (select auth.uid())
  or (role in ('owner', 'staff') and public.is_workspace_member(workspace_id))
);

-- The memberships a caller can read now decide whose profiles they can read.
drop policy "Members can view profiles in their workspaces" on public.profiles;

create policy "Members read the profiles of memberships they can see"
on public.profiles for select
to authenticated
using (
  exists (
    select 1 from public.workspace_members m
    where m.user_id = profiles.id
  )
);
After the fix
Sam reads 3 profiles and 3 memberships: his own and the studio's 2 staff, not Priya's. Maya still reads all 4 of her studio's members.
Fix nowSecurityChecklist item 3About 2 hours

11.Removing a client doesn't remove their access

Where
supabase/migrations/20260914101233_6410e044….sql:140 lets an owner remove a member, but no client rule asks about the membership. supabase/migrations/20260914101233_6410e044….sql:165 gives a client the projects whose client row carries their user ID, and the client rules in the fixes for findings 2 to 4 ask the same. Removing the membership leaves that link in place.
Evidence
As Maya, Alder's owner: remove Sam. Then, as Sam: his projects
$ curl -X DELETE 'http://127.0.0.1:55321/rest/v1/workspace_members?workspace_id=eq.a1de5000-0000-4000-8000-000000000001&user_id=eq.33333333-3333-4333-8333-333333333333&select=user_id,role' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Maya's access token>' \
  -H 'Prefer: return=representation'
HTTP 200
...
Memberships Sam has at Alder now: 0.

$ curl -X GET 'http://127.0.0.1:55321/rest/v1/projects?select=name&client_id=eq.c1000000-0000-4000-8000-000000000001&limit=2' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Sam's access token>' \
  -H 'Prefer: count=exact'
HTTP 206
Content-Range: 0-1/10
[
  {
    "name": "Brightline Coffee: Brand refresh"
  },
  ...
What an attacker gets
A client the studio removes keeps their projects, invoices, messages and PDFs for as long as their account exists. Removed from Alder, Sam still read his 10 projects, 20 invoices and 12 messages, and got a signed link to his PDF. A stranger reads the last three here too, through findings 2 to 4. Fixing those doesn't end Sam's access, because their client rules ask the same question about the client row.
The fix
When a membership is deleted, a trigger unlinks that user from the studio's client rows, so every client rule stops matching at once. It runs as the table's owner, with an empty search path, and no API role can call it.
fixes/sql/11-unlink-removed-clients.sql
begin;

create function public.unlink_removed_client()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  update public.clients
  set user_id = null
  where workspace_id = old.workspace_id
    and user_id = old.user_id;
  return old;
end;
$$;

revoke execute on function public.unlink_removed_client() from public, anon, authenticated;

create trigger on_workspace_member_removed
  after delete on public.workspace_members
  for each row execute function public.unlink_removed_client();

commit;
After the fix
Removed from Alder, Sam reads nothing a stranger can't: no projects, invoices or messages, and signing his PDF is refused with HTTP 400.
Fix nowSecurityChecklist item 10About 2 hours

12.Anyone can sign up with an address they don't own and the password 123456

Where
supabase/config.toml:51 and :52 allow 6-character passwords with no rules, and :57 skips email confirmation.
Evidence
Sign up a made-up address with the password 123456. It counts as confirmed at once.
$ curl -X POST 'http://127.0.0.1:55321/auth/v1/signup' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <anon key>' \
  -H 'Content-Type: application/json' \
  -d '{"email": "[email protected]", "password": "123456"}'
HTTP 200
{
  "access_token": "<new account's access token>",
  ...
  "user": {
    ...
    "email": "[email protected]",
    "email_confirmed_at": "2026-11-06T04:24:24.450404339Z",
    ...
What an attacker gets
Anyone gets an account and a session at once, under any address, with a password that's guessed in seconds. That turns every "any signed-in user" rule into "anyone". It also empties the invite fix's address check: whoever holds an invite link can sign up as the invited address and accept it (finding 5). So email confirmation, and a sender to deliver it, ship before invites do.
The fix
Require email confirmation and passwords of at least 10 characters with mixed case and digits. Set up your own email sender first: Supabase's built-in one allows 2 emails an hour. On a paid plan, turn on leaked password protection too.
supabase/config.toml
minimum_password_length = 10
password_requirements = "lower_upper_letters_digits"

[auth.email]
enable_confirmations = true
secure_password_change = true
After the fix
The password 123456 is refused with HTTP 422. A long password gets an account but no session until the address is confirmed, and the confirmation email arrives.

Fix this quarter (5)

None of these exposes data by itself. Finding 15 lets one studio's staff put a project in another studio's client portal. It needs IDs that, once findings 2 and 7 are fixed, only that studio's people and Acme's admins can read. The rest make the next problem likelier or costlier.

Fix this quarterCode and testsAbout 8 hours

13.No tests, and nothing runs them

Where
There are no test files, package.json:6 has no test script, and there's no CI workflow.
Evidence
The 37 database tests written for the fixes, run against today's database
$ supabase test db
...
# Failed test 5: "signed out: no invoices"
#         have: 68
#         want: 0
...
# Failed test 23: "Sam: can't read Priya's PDF through an invoice he writes in a studio of his own"
#         have: 1
#         want: 0
...
# Failed test 31: "after Maya removes Sam, he reads none of his projects, invoices, messages or files"
#         have: 126
#         want: 0
...
# Looks like you failed 35 tests of 37
...
Result: FAIL
Why it matters
Nothing stops the next change from reopening findings 1 to 12. Access rules are exactly the code that looks right and isn't, and Supabase's own guide says you don't know they work until tests prove it.
The fix
Keep the 37 database tests in supabase/tests and run them on every pull request, with a check that fails the build if a secret key reaches the bundle. The tests act as each kind of user, the way the API does, and cover findings 2 to 7, 9 to 11 and 15. The bundle check covers finding 1. Findings 8 and 12 need HTTP tests against a running stack, for the Edge Function and the auth settings. Those come next, with an end-to-end test of sign-in.
.github/workflows/db-tests.yml
name: Access checks

on:
  push:
    branches: [main]
  pull_request:

jobs:
  access-rules:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v1
        with:
          version: 2.31.4
      - run: supabase db start
      - run: supabase test db

  bundle-keys:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build
      - run: scripts/check-bundle-keys.sh dist
After the fix
All 37 tests pass against the fixed database, and 35 of them fail against today's. The bundle check fails today's build and passes the fixed one.
Fix this quarterDataChecklist item 11About 3 hours

14.No restore has been tried, and nothing copies the PDFs

Where
Nothing in the repo dumps or restores the database, or copies the storage bucket.
Evidence
What's in the repo, and what a database backup holds
Files in the app that mention a backup or restore: none
Invoice PDFs in storage: 68. Database backups hold their metadata rows in storage.objects, not the files.
Why it matters
Nothing in the project copies the PDFs, and Supabase's backups don't include them: they hold the database, not the files in storage. If the project were lost, a backup would bring back the 68 invoice rows and none of the 68 PDFs they point to. And until someone restores a backup, nobody knows whether it works, or how long it takes.
The fix
A restore drill, monthly and before risky migrations. It dumps the database with its grants to a file only its owner can read, restores it into a scratch database and compares the two: row counts, row level security, every policy and the API roles' grants. It copies the bucket's files and checks each against its checksum, then deletes the dump. Keep the copies somewhere as safe as production.
scripts/restore-drill.sh, run on the fixed copy
$ scripts/restore-drill.sh
dumped the auth, storage and public schemas, with their grants, to a file only its owner can read
restored into a scratch database, with 0 errors
skipped 18 statements that set default privileges for Supabase's admin roles, which only they can run
row counts match in all 35 tables
row level security and view options match on all 20 tables and views in public and storage
all 60 policies match, word for word
the API roles' grants in public match: 218 on tables and views, 13 on single columns, 21 on functions
copied 68 of 68 files from the invoices bucket; 68 match the MD5 in their storage metadata, 0 had none to compare
deleted the dump and dropped the scratch database
...
DRILL PASSED
After the fix
On the fixed copy, the drill restored all 35 tables with matching row counts, the same row level security, all 60 policies word for word and the API roles' grants on tables, columns and functions. It copied all 68 PDFs, and each matched its checksum. That proves the data and its access rules come back on the same Postgres. It doesn't sign anyone in, run the app, or time a restore of a hosted project.
Fix this quarterSecurityChecklist item 3About 2 hours

15.Projects and invoices can point at another studio's clients

Where
supabase/migrations/20260914101233_6410e044….sql:82 only asks that a project's client exists, and supabase/migrations/20260916143005_72796a33….sql:5 and :6 the same of an invoice's project and client. Nothing asks that they belong to the row's own studio.
Evidence
As Tom, Juniper's owner: a Juniper project for Alder's client. Then, as Sam: his projects
$ curl -X POST 'http://127.0.0.1:55321/rest/v1/projects' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Tom's access token>' \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=minimal' \
  -d '{"workspace_id": "b1de5000-0000-4000-8000-000000000002", "client_id": "c1000000-0000-4000-8000-000000000001", "name": "Juniper Dev: Website build"}'
HTTP 201

$ curl -X GET 'http://127.0.0.1:55321/rest/v1/projects?select=name,workspace_id&client_id=eq.c1000000-0000-4000-8000-000000000001&workspace_id=eq.b1de5000-0000-4000-8000-000000000002' \
  -H 'apikey: <anon key>' \
  -H 'Authorization: Bearer <Sam's access token>' \
  -H 'Prefer: count=exact'
HTTP 200
Content-Range: 0-0/1
[
  {
    "name": "Juniper Dev: Website build",
    "workspace_id": "b1de5000-0000-4000-8000-000000000002"
  }
]
Why it matters
One studio's staff can put a project into another studio's client portal and message that client from it, or tie their invoices to another studio's projects. It needs the other studio's client or project ID, which only that studio's people and Acme's admins can read once findings 2 and 7 are fixed, so it's low.
The fix
Composite foreign keys, so a project's client, and an invoice's client and project, must belong to the row's own studio. Each one needs a unique key on the referenced pair. Deleting a client or a project still clears just the reference.
fixes/sql/15-same-studio-references.sql (excerpt)
alter table public.clients add constraint clients_id_workspace_id_key unique (id, workspace_id);
alter table public.projects add constraint projects_id_workspace_id_key unique (id, workspace_id);

alter table public.projects drop constraint projects_client_id_fkey;
alter table public.projects
  add constraint projects_client_in_same_studio_fkey
  foreign key (client_id, workspace_id) references public.clients (id, workspace_id)
  on delete set null (client_id);

-- Plus the same for an invoice's client and project.
After the fix
Tom's project for Alder's client and his invoice on an Alder project are refused with HTTP 409. Owners still create projects for their own clients, and staff still create invoices.
Fix this quarterRunning costAbout 2 hours

16.The dashboard sends 31 requests every 10 seconds

Where
src/pages/Dashboard.tsx:25 fetches each project's invoices in a loop, one request after another, and line 15 reruns the whole set every 10 seconds.
Evidence
The dashboard's queries, replayed with curl as Maya (Alder Studio, 30 projects)
Code: Dashboard.tsx:15:    refetchInterval: 10000,
Code: Dashboard.tsx:25:      for (const project of projects) {
Requests: 31. Response bytes: 7891. Wall time on this machine: 479 ms.
The same data in one request with an embedded select: 1 request, 8371 bytes, 211 ms.
At one refresh every 10 seconds, one open tab sends 11160 requests an hour, about 2774 KB of responses.
Why it matters
Each new project adds a request and makes the page slower, and every open tab repeats the whole set six times a minute. Supabase doesn't charge per database request, but the load lands on the database, and the data sent out, which is billed, grows with it.
The fix
Fetch the projects with their invoices in one request, and refresh when the tab regains focus instead of on a timer.
src/pages/Dashboard.tsx (excerpt)
const { data, error } = await supabase
  .from("projects")
  .select("id, name, status, due_date, client_id, invoices(amount_cents, status)")
  .eq("workspace_id", workspace!.id)
  .order("created_at", { ascending: false });
After the fix
One request returns the same 30 projects with their invoices.
Fix this quarterDataChecklist item 12About 2 hours

17.Policies run auth.uid() once per row, and foreign keys have no indexes

Where
Supabase's performance lints, from the local advisor. Eleven policies call auth.uid() directly, such as supabase/migrations/20260914101233_6410e044….sql:94. The advisor also lists 14 foreign keys without an index. Three of them already lead a composite index, so 11 need one.
Evidence
The advisor's performance lints, and the foreign keys the database shows with no index
Performance lints from the local advisor (output/1-vulnerable/checks/advisor-lints.json):
    14 INFO  unindexed_foreign_keys
     1 INFO  unused_index
    11 WARN  auth_rls_initplan
    20 WARN  multiple_permissive_policies
...
Foreign keys whose columns lead no index, from the database itself:
  clients.clients_user_id_fkey
  clients.clients_workspace_id_fkey
  invoices.invoices_client_id_fkey
  ... (8 more)
Why it matters
Nothing you'd notice at today's few hundred rows. It starts to matter in the tens of thousands, and it's cheap to fix while the policies are being rewritten anyway. The 20 warnings about several permissive policies come from a staff policy beside a client policy on the same table, which reads more clearly than one combined rule, so leave those.
The fix
Index the 11 foreign keys, and write (select auth.uid()) so Postgres works it out once per query instead of once per row. The client and project indexes lead with both columns of finding 15's new keys, so they cover those too.
fixes/sql/17-indexes-and-policy-calls.sql (excerpt)
create index if not exists clients_user_id_idx on public.clients (user_id);
create index if not exists invoices_client_id_workspace_id_idx on public.invoices (client_id, workspace_id);

drop policy "Users can view their own profile" on public.profiles;
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );
After the fix
No policy calls auth.uid() per row, and every foreign key leads an index. The advisor still lists the three that were already covered.

Leave alone (4)

A scanner or our own checks flag these. We looked, and they're fine as they are.

Leave aloneSecurityChecklist item 8

18.The public key is written into the code

Where
src/integrations/supabase/client.ts:6, where many Lovable projects keep it.
Why it can stay
Secret scanners often flag it, because it looks like a token. The anon key is public by design. For database tables, its access is limited by grants and row level security. Every callable Storage route and Edge Function needs its own authorization review: finding 8 is one that failed it. In the fixed copy, signed out, the health response, the helper functions' answers and the plans remain available. This legacy key still goes when the leaked service-role key is retired (finding 1).
Leave aloneSecurityChecklist item 2

19.Anyone can read the plans table

Where
supabase/migrations/20261008152210_d5ff47f7….sql:13, a select policy with using (true).
Why it can stay
Our checklist query lists it as "allows every row", correctly. It holds the plan names and limits that the pricing page shows to everyone.
Leave aloneSecurityChecklist item 9

20.The health function skips the JWT check

Where
supabase/config.toml:73, for supabase/functions/health.
Why it can stay
It returns a status and the time, reads no data and holds no keys. An uptime monitor has to call it without signing in.
Leave aloneSecurityChecklist item 6

21.Four other SECURITY DEFINER functions

Where
is_workspace_member and is_workspace_staff (supabase/migrations/20260914101233_6410e044….sql:36 and :50), is_admin (supabase/migrations/20261008152210_d5ff47f7….sql:30) and the sign-up trigger handle_new_user (supabase/migrations/20260914101233_6410e044….sql:181).
Why it can stay
The checklist query and the advisor list them all. The first three only answer questions about the caller: signed out, they return false, and Sam learns only his own membership. The trigger can't be called over the API, which answers HTTP 404. No disclosure or privilege escalation was shown. The advisor also flags their search path, which is set to public. Neither API role has CREATE on public, which we checked on the local stack, so neither can plant an object there. They still fail the advisor's rule, so set search_path = '' when you next touch them, and check the CREATE grants on the hosted project.

The 90-day plan

The order to work in, so the team can start the week after the walkthrough. The first week covers every fix-now finding and ends with every attack rerun before release. Hours are estimated code changes on this demo. They exclude moving the hosted project to new keys, setting up an email sender, QA and release.

  1. Week 1

    Close the open doors

    About 28 hours

    • Day 1: retire the leaked keys. Move the admin page's calls into the Edge Function, move the browser and functions to the new keys, deactivate the legacy ones and revoke the old JWT secret. Rotate the PDF service's key (findings 1 and 9).
    • Day 2: the fixes that keep each studio's data to itself, as one migration: invoices, files, messages, the view, roles and plans, who sees whom, and removal (findings 2 to 4, 6, 7, 10 and 11). Then the invoice function (finding 8).
    • Day 3: your own email sender, then email confirmation and stronger passwords (finding 12). Invites ship after them (finding 5).
    • Day 4: on staging, rerun every attack, the crafted invoices and the removed client among them, and the 37 database tests. Release only when every attack is refused and every test passes.
  2. Weeks 2 to 4

    Make it stay fixed

    About 10 hours

    • The 37 database tests and the bundle check in CI on every pull request, then HTTP tests for the Edge Functions and auth settings, and a sign-in test (finding 13).
    • Keys that keep projects and invoices inside their own studio (finding 15).
  3. Month 2

    Data and speed

    About 7 hours

    • The first restore drill, with the bucket copied and checked, then a timed restore of the hosted project into a scratch project (finding 14).
    • The dashboard's single query, and the indexes and policy rewrites (findings 16 and 17).
  4. Month 3

    Keep it that way

    • Repeat the restore drill, and add it to the monthly checklist.
    • Before each new table, function or bucket ships: its tests, and the checklist's queries.

The 13 checks

Every item on our security checklist for Lovable apps, and what it found here.

  1. 1

    RLS is on for every table

    messages has it off. Finding 4.

  2. 2

    No policy lets everyone through

    invoices does, and the storage policies check only the bucket. plans does on purpose. Findings 2, 3 and 19.

  3. 3

    Every policy checks the owner

    The membership policy checks the user, not who may grant access. Reading the policies found three more: clients see each other, removal leaves access in place, and references can cross studios. Findings 5, 10, 11 and 15.

  4. 4

    Users can't change their role or plan

    They can: profiles.role, workspaces.plan, workspace_members.role. Findings 5 and 6.

  5. 5

    Views respect RLS

    client_overview doesn't. Finding 7.

  6. 6

    SECURITY DEFINER functions aren't open to everyone

    get_pdf_settings() hands out a key. Four others are fine. Findings 9 and 21.

  7. 7

    Private files are in private buckets

    The invoices bucket is public. Finding 3.

  8. 8

    The secret key never reaches the browser

    It does, through a VITE_ variable. The anon key in the code is fine. Findings 1 and 18.

  9. 9

    Edge Functions check who's calling

    invoice-pdf doesn't. health doesn't need to. Findings 8 and 20.

  10. 10

    Auth settings are production settings

    No email confirmation, 6-character passwords. Finding 12.

  11. 11

    You can restore your data, and you've tried

    Never tried, and files aren't covered. Finding 14.

  12. 12

    The scans are clean

    The advisor reports 2 errors, 5 security warnings and 31 performance warnings. Lovable's scans don't apply here. Findings 4, 7, 17 and 21.

  13. 13

    We tried to break in

    We ran 16 attacks across findings 1 to 12 and 15. All worked before the fixes, failed after them, and worked again when we reverted.

What we didn't check

  • Production. Every attack ran on a local copy of the demo app, in the Supabase CLI's local stack on our own machine. Nothing hosted was touched, so this isn't a penetration test of a live system.
  • Hosted-only settings. Leaked password protection, the production email sender and rate limits, network restrictions and backups on a paid plan only exist on a hosted project, as do the advisor's newer lints. In a client's audit we read them from the dashboard.
  • The new API keys. The local stack issues only the legacy keys, so the Edge Functions ran on their fallback. Reading SUPABASE_SECRET_KEYS and moving a project to the new keys weren't tested.
  • Newer tooling. The run used Supabase CLI 2.31.4. Its local stack keeps the API grants projects had before October 30, 2026, the defaults the demo was written against. CLI 3.0.0 came out on November 2, and we kept 2.31.4.
  • GraphQL. The GraphQL endpoint sits on the same grants and row level security, but we didn't test it separately.
  • Lovable's scans. Acme follows Lovable's layout but wasn't built in Lovable, so its Quick and Deep scans didn't run.
  • The business rules. We decided who should see what ourselves. In a client's audit we agree that with the team first, since no scanner can know it.
  • Dependencies. They were installed on November 6, 2026, and we recorded their versions, but didn't run them against a vulnerability database.
  • The PDF service. It's made up, so the new render step was tested up to the point where it would call the service.
  • CI. The workflow file was written, and the commands its two jobs run were run locally. It hasn't run on GitHub.
  • Compliance. It isn't a compliance audit.

Versions and dates

Everything ran on November 6, 2026, in the Supabase CLI's local stack. We kept the scripts and every request and response, and can rerun any of it on a call.

Supabase CLI
2.31.4
Postgres
17.4 (supabase/postgres 17.4.1.055)
Auth
GoTrue v2.177.0
API
PostgREST v12.2.12
Storage
storage-api v1.25.7
Edge Functions
edge-runtime v1.67.4
Studio and its advisor
2025.07.07
App
Vite 5.4.21, React 18.3.1, supabase-js 2.119.0

Want this report for your own app?

A code audit takes two weeks, at a fixed price from $5,000 agreed before we start. You get a report like this one on your own code, a 60-minute walkthrough and a fixed quote for the top fixes.