DEV Community

Aakash Verma
Aakash Verma

Posted on Originally published at lovable2live.com

Lovable Security: What the Scan Won't Catch

Lovable security has gotten a lot better this year, and I want to say that up front because the rest of this post is going to sound grumpy. There's a Quick security scan that runs every time you publish, a Deep security scan that reads your code, and a chat-based Security Review you can ask for. A year ago the most common thing we found in a Lovable app was a table with RLS switched off entirely. We almost never see that now.

What we see instead is an app with a green "passed" badge and a policy like this on the profiles table:

create policy "Users can view profiles"
on public.profiles for select
using (auth.uid() is not null);
Enter fullscreen mode Exit fullscreen mode

Read it slowly. It says any logged-in user can read every row. Not their own row. Every row. Emails, phone numbers, the stripe_customer_id column someone added in week three, the lot. RLS is enabled, a policy exists, the policy even checks auth. A linter looking for "is there a policy and does it mention the user" is satisfied. The policy is still wrong, because the line should be auth.uid() = id.

That is the whole problem with automated checks in one example. They're good at presence. Is RLS on, is there a secret in the bundle, is a dependency on a known-bad version. They're weak at intent, and intent is where real Lovable apps leak.

How we actually test it

The fastest test needs no tools at all. Sign up to the app twice with two email addresses, open dev tools on account B, and copy the request the app makes to Supabase when it loads account B's data. Now change the filter. If the app fetched profiles?id=eq.<B's id>, delete the filter and send it again. If you get back more than one row, you have a leak, whatever the scanner said.

Then repeat that for every table the frontend touches. On a typical Lovable SaaS that's 8 to 15 tables, and it takes about an hour with curl and the anon key. The anon key is public by design, by the way. It ships in your JS bundle and that's fine. Founders panic about it constantly and it's the one thing in the bundle that isn't a problem. The anon key plus bad RLS is the problem.

The four holes we keep finding

Policies that check login but not ownership. The example above. It shows up on profiles, messages, orders and anything called documents. Usually the AI wrote a correct policy first, then someone hit an error ("new row violates row-level security policy") and asked Lovable to fix it, and the fix was to loosen the policy until the error went away.

Edge functions that trust the body. A function receives { "user_id": "...", "plan": "pro" } from the client and writes it with the service role key. The service role key bypasses RLS completely, so the function is now the only thing standing between a user and every row in the database, and it believes whatever user ID the browser sends. Any function that uses the service role key has to read the user from the JWT (supabase.auth.getUser() with the request's Authorization header) and ignore anything the client claims about who it is. Also check supabase/config.toml for verify_jwt = false. Sometimes it's right, for a Stripe webhook for example. Often it was flipped to make a CORS error go away.

Stripe webhooks without signature checks. Speaking of which. A webhook function that parses event.type === "checkout.session.completed" and upgrades the user, without calling stripe.webhooks.constructEventAsync with the signing secret, will upgrade anyone who can POST JSON to a public URL. We found one of these on a client's app that had been live for four months with paying customers. Nobody had abused it, as far as their Stripe logs could tell us, and I still wouldn't want to bet a company on "as far as we can tell."

Public storage buckets. Avatars in a public bucket are fine. ID scans, invoices and contract PDFs in a public bucket, with predictable paths like uploads/<user_id>/passport.jpg, are not. The bucket being public was usually a choice made in five seconds to make an image preview work.

The client who passed

One founder came to us with a marketplace app, around 600 users, and a screenshot of a clean scan. They wanted help with performance, not security. While we were profiling, I noticed the seller dashboard pulled payout data with a query filtered on the client side. The request itself asked for payouts?select=*, and the React component filtered the array down to the current seller with .filter().

So every seller's browser downloaded every seller's payout history, bank last-four digits included, and then politely hid the other rows. The table had RLS. The policy was using (true) for authenticated users, which had been added, according to the chat history, to fix "payouts not showing." Fixing the policy took ten minutes. Finding every other place in the code that depended on the loose policy took most of two days, because four screens broke the moment the policy was tightened.

That's the part nobody budgets for. Tightening security in a Lovable app always breaks something, because the loose policies exist precisely to make features work.

What I'd do this week

Run the Deep scan, not just the Quick one, and actually read the findings instead of clicking "fix all." Then do the two-account test above on your three most sensitive tables. Grep your edge functions for SUPABASE_SERVICE_ROLE_KEY and check every one of them reads the user from the token. Look at your storage buckets in the Supabase dashboard and ask, for each public one, whether you'd be fine with the whole bucket on a forum.

If your app is on Lovable Cloud, some of this is harder to reach, since you don't get the full Supabase dashboard and you can't easily run your own SQL against policies. That's one of the reasons founders move their backend to their own Supabase project once real users arrive, and it's what our Cloud to Supabase migration covers, policies included.

And if you'd rather someone else did the two-account test across the whole app, that's our Lovable security audit: a written report, the fixes and a re-test. Here's how we work, or just book a call and bring the scan report.


Originally published on lovable2live.com. Aakash Verma, software developer and founder of Lovable 2 Live.

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The auth.uid() is not null example is the best one-sentence summary of the presence-vs-intent gap I've read — I scan public Lovable-generated Supabase repos for exactly this class, and the profile-policy shape is the most common real leak I find (three recent cases with fixes: dev.to/cekuu35/3-supabase-rls-leak...).

Two additions from the scan work that make the manual testing faster:

1. The wrong-predicate class is findable schema-wide in one query, before curl hour. The two-user test catches it at runtime; this catches every instance at the SQL level in ~30 seconds — it returns any permissive policy whose predicate contains no ownership equality, which sweeps both the is not null shape and plain USING (true) in one result:

select tablename, policyname, roles, qual
from pg_policies
where qual not ilike '%=%'
order by tablename;
Enter fullscreen mode Exit fullscreen mode

Rows here are either intentional-public (your call) or exactly the "passed the scan, leaks every row" family. I run this before any runtime testing now — it usually shortens the curl hour to ten minutes.

2. A fifth hole worth adding to the four: the missing TO clause. Different root cause from the wrong-predicate class, same green-badge outcome. From the audits: policies named "Users can view X" written with no TO clause — Postgres evaluates a policy with no TO for PUBLIC, anon included. The AI writes the right intent into the policy name and drops the clause, and no scanner checks it. The catch: select tablename, policyname, roles from pg_policies where roles::text = '{public}';

And one dated note for this exact audience: Lovable apps keep generating tables after launch. Supabase's October 30 change stops auto-granting anon/authenticated on new tables in existing projects — so the next class of Lovable bug is "worked in preview, 42501: permission denied in prod" starting late October. One extra line in the pre-launch checklist covers it (the grant census + fix patterns: dev.to/cekuu35/supabase-permission...).

The Stripe webhook point deserves its own post, by the way — the no-signature-check shape is even more common than the RLS classes in what I see, and the "live for four months with paying customers" version is the nightmare scenario most founders don't test until after it would have mattered.

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

"Good at presence, weak at intent" is the whole thing, and the auth.uid() is not null example on profiles is the canonical green-badge leak — a presence check can't see it because RLS is on and a policy does reference auth. Worth adding the two probes that catch the class without reading every policy by hand:

  1. Sign up twice (user A and B). With B's token, request A's row by id from the client. Any 200 with data = the policy is unscoped, whatever its name says.
  2. One query for the whole schema:
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual is null or with_check is null or qual !~ 'uid\(\)');
Enter fullscreen mode Exit fullscreen mode

Anything it returns is either unscoped or missing a write check. The scan's job ends at "is there a policy"; this is the "is the policy saying what you meant" layer, which is where Lovable apps still leak. I packaged the notes/appointments variant as a runnable before/after so you can see the red→green locally with no cloud project: github.com/cekuu35/supabase-rls-le...

Some comments may only be visible to logged-in visitors. Sign in to view all comments.