Sound familiar?
You built it in Lovable over a few weekends. Supabase underneath, a payment provider for billing, a domain, a logo. Agencies signed up, some of them started paying, and the invoices they send through your app carry their own clients' names, VAT numbers and amounts. You have never opened the Policies tab in the Supabase dashboard. There is a small "RLS disabled" badge on three of your tables and you have been meaning to look at it. The app works, customers are happy, and the thing you are most afraid of is a bug in the PDF export.
What they sent us
A solo founder. An invoicing tool for small agencies, built in Lovable in about seven weeks, Supabase for auth, database and storage, a payment provider for subscriptions. Forty agencies were paying. Between them they had about 1,200 end clients on file and roughly 4,000 invoices, each with line items, amounts, bank details and the client's tax ID.
Our due-diligence scan runs 19 dimensions. This app scored 1.6 out of 5, "developing". 76 findings: 4 critical, 19 high, 41 medium, 12 low. The security dimension alone scored 1.0, the lowest mark on the scale. The four criticals took us under an hour to find and under a minute each to reproduce.
First: Row Level Security was off on invoices, clients and line_items. The other eight tables had policies, because Lovable had generated them from its template early on. The three that mattered were added later, by hand, through the SQL editor, and never got one. With RLS off, the public anon key reads everything. We pulled the anon key out of a Network request, logged out, and ran one curl against /rest/v1/invoices?select=*. 4,000 rows came back.
Second: the service-role key was in the JavaScript bundle. Lovable had been told to store it as VITE_SUPABASE_SERVICE_ROLE_KEY, and anything with the VITE_ prefix is compiled into the client. We found it in Sources by searching for the JWT header prefix and decoding the payload: "role":"service_role". That key bypasses RLS even when RLS is on. Turning on policies without rotating it would have changed nothing.
Third: the edge function that emailed invoice PDFs took org_id from the request body and used the service key to fetch the invoice. Whoever you were, you could ask for any organisation's invoice by number and it would be mailed to the address you supplied. Fourth: no login throttling of any kind, no audit log, and the project's API logs record that a request happened, not which rows it returned. Nobody could have said, for any past day, who had read what.
None of this was visible from the product. Login worked, invoices rendered, PDFs went out, money arrived every month. The founder had shipped a feature a week for two months and never had a reason to open a Policies tab.
The app worked perfectly, which is exactly why nobody looked underneath it.
What would have happened
What actually happens
On day 9 a scanner that walks public Supabase project URLs finds /rest/v1/invoices answering with rows. It does not need to log in. By the evening the three tables are in a paste: 40 agencies, 1,200 of their clients, every amount, every bank detail. On day 10 one of the agencies gets a phishing email that quotes a real invoice number and a real amount, and replies to the founder asking how the sender knew. On day 10 the 72-hour breach-notification clock starts. Because nothing was logged, the founder cannot say which rows were read, so the regulator and all 40 customers are told: all of them, since launch. On day 13 the first agency cancels and asks for its data deleted. By day 30 the founder is answering a data-protection questionnaire from a customer's own client, and the service-role key is still in a bundle cached by a CDN somewhere.
Forty-eight hours
Hour 0 to 6: stop the bleeding, then break the app on purpose. First thing, before any discussion: rotate the service-role key in the dashboard. The copy in the bundle stopped working at that second. Then we removed the VITE_ secret from the Lovable project settings, so the next publish could not put it back. Then we turned RLS on for all eleven tables, including the eight that already had policies, with force row level security so even the table owner is subject to them.
Turning RLS on with no policy means every query returns zero rows. The app went blank. That is the correct state: fail closed, then open exactly the doors you can defend. We did it on a branch of the project first, so production stayed up while policies were written and tested against a copy of the data.
Hour 6 to 18: one policy per table per verb, tenant decided by the token. The app has organisations; a user belongs to one or more through a memberships table. So the question every policy has to answer is "which organisations does the caller belong to", and the caller is auth.uid(), which Postgres reads out of the signed JWT. Not from a header. Not from the body. Not from a column the client sent.
-- Turn it on. From this second the table returns zero rows.
alter table public.invoices enable row level security;
alter table public.invoices force row level security;
-- Who is asking? auth.uid() comes from the signed JWT.
create function public.my_org_ids() returns setof uuid
language sql stable security definer set search_path = public as $$
select org_id from public.memberships where user_id = auth.uid()
$$;
-- Members read their own org. No policy for anon = anon reads nothing.
create policy "invoices_select_own_org" on public.invoices
for select to authenticated using (org_id in (select public.my_org_ids()));
Three things to copy exactly. The helper is security definer so it can read memberships even though that table has its own RLS. The policy wraps the helper in (select …) so Postgres evaluates it once per query, not once per row; without the brackets a 4,000-row scan runs the lookup 4,000 times. And the policy is for authenticated only. There is no policy for anon, and we revoked the anon role's table grants too, so a logged-out request gets an empty array, not an error that confirms the table exists.
create policy "invoices_insert_own_org" on public.invoices
for insert to authenticated
with check (org_id in (select public.my_org_ids()));
-- line_items has no org_id: it inherits through the parent invoice.
create policy "line_items_via_invoice" on public.line_items
for all to authenticated
using (exists (select 1 from public.invoices i
where i.id = line_items.invoice_id
and i.org_id in (select public.my_org_ids())))
with check (exists (select 1 from public.invoices i
where i.id = line_items.invoice_id and i.org_id in (select public.my_org_ids())));
Reads need using. Writes need with check, or a member of organisation A can insert a row stamped with organisation B's id and it will save. Child tables without their own org_id get their policy through the parent. That second snippet is the one Lovable never writes by itself, because the template only knows tables with a user_id column.
In the same window the service-role key left the client for good. The three operations that need it, mailing a PDF, handling the payment webhook and inviting a member, became edge functions with the key in function secrets. Everything else goes through the anon key and a policy. A function secret is not a Lovable environment variable; it never touches the bundle.
Hour 18 to 36: identity, lockout, audit. The edge functions had been trusting the body. Now they verify the JWT, look up the membership, and refuse before doing any work.
const jwt = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
const { data: { user }, error } = await userClient.auth.getUser(jwt);
if (error || !user) return json({ error: "unauthenticated" }, 401);
// The org comes from membership, never from the request body.
const { data: m } = await admin.from("memberships").select("org_id")
.eq("user_id", user.id).eq("org_id", body.org_id).maybeSingle();
if (!m) return json({ error: "forbidden" }, 403);
// Only now does the service-role client touch invoices.
The admin client holds the service key and is created from Deno.env. If that variable is missing, shorter than 32 characters, has fewer than 8 distinct characters, or still says replace_with_, the function throws at start-up and every request returns 500. It never falls back to the anon key, because a function that silently runs with fewer privileges is a function that silently fails to do its job.
Login lockout went into Postgres, not into the frontend, because the frontend is the attacker's machine. Supabase's built-in rate limit is per IP; a slow guess against one account from many addresses never trips it. Ours counts per account.
create function public.reserve_login_attempt(p_email text) returns boolean
language plpgsql security definer set search_path = public as $$
declare h bytea := sha256(lower(p_email)::bytea); n int;
begin
-- Reserve first, count second. A check-then-act version has a race.
insert into public.login_attempts(email_hash, at) values (h, now());
select count(*) into n from public.login_attempts
where email_hash = h and at > now() - interval '15 minutes';
delete from public.login_attempts where at < now() - interval '1 day'; -- prune
return n <= 5;
end $$;
From our desk
The first version of that function counted attempts and then inserted. Our security reviewer rejected it: fifty parallel requests all count four, all pass, and the lockout never fires. Reserve-then-check fixed it. The second version was rejected too, because the attempts table grew forever. The prune line is a reviewer's finding turned into a mandatory task. Every rejection works that way; nothing is "noted for later".
The audit log is a table that can only grow. A trigger raises on any update or delete, service role included, and the functions write a row for every send, export and membership change with the verified user id, not the one the client claimed. If day 9 ever comes, the founder can answer the only question that matters: which rows, by whom, when.
Smaller things in the same window, each a past reviewer's rejection reason: raw Postgres error text reached the browser through the functions, now it is a generic message and a request id; bulk PDF export had no limit, now the fourth concurrent export per organisation gets a 429; an unknown query parameter returns 422 before any query runs.
Hour 36 to 48: prove it, then ship it. The security suite has 214 tests and one shape: sign in as tenant B and try to read, insert, update or delete tenant A's rows, on every table, through every function, as anon, as B and as a B member who was removed yesterday. The expected result is zero rows, not an error. RLS filters silently, so a test that asserts on an error message passes for the wrong reason. Each test asserts on the count.
The suite runs only against a local stack started with supabase start. The bootstrap refuses any project URL that is not localhost:54321: an allow-list, not a deny-list of hosted hostnames. The first version was a deny-list. It was rejected, because a deny-list is a list of the mistakes you have already thought of.
Twenty-eight work items went through the same gate: develop, verify by a separate reviewer running every suite, then three independent reviews for architecture, security and functional completeness, scored 0 to 5. Below 3 is a reject; any critical security finding is a reject regardless. Eleven items came back on the first pass. Then the migrations were pushed, Lovable republished a bundle with exactly one key in it, and the security advisor reported zero warnings for the first time in the project's life.
What it looks like now
Readiness moved from 1.6 to 3.8, "strong". The security dimension went from 1.0 to 4.2. Findings went from 4 critical, 19 high, 41 medium and 12 low to 0, 5, 24 and 30; the low count rose because things we could now see and name replaced things nobody could see at all. No dimension went down.
| Before | After |
|---|---|
| 3 tables readable by anyone with the anon key | 11 of 11 tables under RLS, policies per tenant and per verb |
| service_role key compiled into the bundle | One key in the browser; service key only in function secrets, rotated |
Functions trusted org_id from the request body | Actor is the verified JWT plus a membership row, or 401/403 before any work |
| No per-account login limit | 5 attempts per 15 minutes per account, reserve-then-check, pruned daily |
| No record of who read what | Append-only audit log, trigger-enforced, verified user id on every row |
| Missing secret fell back to the anon key | Missing, short or placeholder secret refuses to start |
| 0 security tests; a green build meant the UI rendered | 214 tests read as the wrong tenant on every table and function, every deploy |
| Security advisor: 3 RLS warnings, never opened | Security advisor: 0 warnings, checked in CI |
Nothing about the product changed. Same screens, same PDFs, same prices. The customers noticed nothing, which is the point: the last mile is not a feature, it is what keeps the features true when someone hostile shows up.
Do this tonight
Do this tonight
1. Read your own tables logged out. Open your app, DevTools, Network tab, filter for rest/v1. Click any request and copy the value of the apikey header; that is your anon key and it is meant to be public. Log out. In a terminal: curl "https://YOURREF.supabase.co/rest/v1/invoices?select=*&limit=5" -H "apikey: KEY" -H "Authorization: Bearer KEY". Rows back means RLS is off or a policy says using (true). [] is the answer you want. Repeat for every table name you can see in Network. Then, in the dashboard: Database, Advisors, and read every line under "RLS disabled in public".
2. Search your bundle for the wrong key. DevTools, Sources, press Cmd+Opt+F (Ctrl+Shift+F) and search eyJhbGciOi, the start of every JWT. For each hit, take the middle segment and run echo 'SEGMENT' | base64 -d. If any payload contains "role":"service_role", rotate that key now, tonight, before you read the rest of this page. Then look at your environment variables: every one that starts with VITE_ is in the bundle, whatever it is called.
3. Try to get locked out. Log out, enter your own email with a wrong password twenty times as fast as you can. If the twentieth attempt behaves exactly like the first, there is no per-account lockout. The dashboard rate limit under Authentication is per IP address; it does not count against a single account.
Those three checks find open doors. They will not tell you whether a policy on a child table leaks through a join, whether the function you added last week trusts the body again, whether anon still has a table grant behind a policy that looks fine, or whether the next prompt you give Lovable quietly recreates a table without RLS. That is what 214 tests on every deploy are for, and it is what prodready did for this founder in 48 hours.
The rule
The rule
If the anon key can read it, the internet can read it. RLS is not a switch you turn on; it is a test you run against every table, as the wrong tenant, on every deploy.
The app worked perfectly, which is exactly why nobody looked underneath it.— Solo founder