Success story · SaaS · Invoicing

Every customer could read every other customer's invoices. Forty of them were paying.

A Lovable-built invoicing tool with Supabase behind it. Beautiful UI, real revenue, Row Level Security switched off on the three tables that mattered and the service-role key sitting in the JavaScript bundle. The founder found out from us, not from a customer. That was luck.

B2B SaaS, invoicing for small agenciesBuilt with Lovable + SupabaseSolo founderReadiness 1.6 → 3.848 hours
Success story · SaaS · Invoicing
3tables readable by anyone→214security tests added
Every customer could read every other customer's invoices. Forty of them were paying.
prodreadyBuilt with Lovable + Supabase · production-ready in 48 hours
3
tables readable by anyone
40
paying customers at risk
100%
tables under RLS after
214
security tests added

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.

Browser (Lovable bundle) anon key service_role key, in the JS org_id chosen by the client logged in or not, same result Supabase project invoices RLS off, 4,000 rows clients RLS off, 1,200 rows line_items RLS off Edge function trusts org_id from body mails any invoice anywhere Auth no lockout per account no audit log REST: any table, any row Anyone holding the anon key can SELECT * from all three tables.
BEFORE. Both keys in the bundle, three tables without RLS, one function that believed whatever org_id it was sent.

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.

Rotate key. RLS on. App goes blank. Good. Policies per tenant. Service key moves to functions. Signed identity. Lockout. Append-only audit log. Fail-closed env. 214 tests. 3 reviewers. Deploy. Advisor: 0 warnings. 0–6 h 6–18 h 18–36 h 36–48 h 0 h 6 h 18 h 36 h 48 h Production stayed up: policies written on a branch, tested, then pushed. Every item: develop, verify, three reviewers, integrate. 11 of 28 rejected first pass.
TIMELINE. Four segments, 0 to 48 hours. The first six were the emergency; the last twelve were proving it.

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

Browser (Lovable bundle) anon key only signed user JWT no org_id of its own choosing 214 security tests every table, as the wrong tenant local stack only, every deploy Supabase project RLS: policy per tenant on 11 of 11 tables invoices RLS on, own org clients RLS on, own org line_items RLS on, via parent Edge functions service key in secrets only actor = verified JWT + membership missing secret = refuse to start Auth + audit lockout per account, 5 in 15 min reserve-then-check, pruned append-only audit log REST + JWT functions
AFTER. One key in the browser, a policy on every table, the service key behind functions that verify who is calling before they do anything.

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.

BeforeAfter
3 tables readable by anyone with the anon key11 of 11 tables under RLS, policies per tenant and per verb
service_role key compiled into the bundleOne key in the browser; service key only in function secrets, rotated
Functions trusted org_id from the request bodyActor is the verified JWT plus a membership row, or 401/403 before any work
No per-account login limit5 attempts per 15 minutes per account, reserve-then-check, pruned daily
No record of who read whatAppend-only audit log, trigger-enforced, verified user id on every row
Missing secret fell back to the anon keyMissing, short or placeholder secret refuses to start
0 security tests; a green build meant the UI rendered214 tests read as the wrong tenant on every table and function, every deploy
Security advisor: 3 RLS warnings, never openedSecurity 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

Send us what you have.

The readiness scan is free — install our MCP and your own coding agent runs it. When you want it fixed, send us what you have: fixed scope, fixed price, agreed before the clock starts. 48 hours later, it's production-ready — on your own cloud, with the tests to prove it.

Feed the machineWatch the 49-second film