Is my Lovable app secure? A 10-minute check
· Arketo team
Nobody can answer "is my app secure" from the outside, and anyone who says otherwise is selling something. But five specific things go wrong in prompt-built apps often enough to be worth ten minutes of your evening, and all five can be checked from a browser and a terminal, without reading a line of the code your assistant wrote.
Run them in order. Anything that fails is worth fixing before you move to the next one.
1. The service-role key that shipped to the browser
Supabase hands you two keys. The anon key is meant to be public, and it's only safe because row-level security stands behind it. The service-role key bypasses row-level security entirely, by design, and belongs on a server and nowhere else. It lands in frontends constantly, because pasting it there is what makes the query work on the first try.
To check: open your live site, view source, and search the page and every .js file it loads for service_role. Then search for eyJ, which is how every Supabase key starts. If you find one and aren't sure which it is, copy the middle section between the dots and decode it as base64 locally, in a terminal or in your assistant. Don't paste it into a decoder website; that's handing a stranger the key you're investigating. The decoded JSON has a role field, and it says either anon or service_role.
If it says service_role, that key is public and has been for as long as the site has been up. Rotate it in the Supabase dashboard first, then move whatever needed it to the server.
2. Row-level security on every table, not the one you remember
RLS is per-table, and it's enabled per-table, which is the whole problem: you turned it on for profiles during the security conversation, and the three tables added in prompts after that conversation went out unprotected.
To check, in two passes. First the dashboard: Table Editor, and look at the RLS badge on every table in the list. Then the real test, because a policy that exists can still be a policy that allows everything. Log out, open a private window, and ask the API directly with only the public anon key:
curl "https://<project-ref>.supabase.co/rest/v1/<table>?select=*" \
-H "apikey: <your anon key>"
An empty array is a pass. Rows coming back to a logged-out stranger is not a warning sign, it's the vulnerability itself, already exploited by you in one command. Run it against every table that holds anything a user would not want read aloud.
3. Rate limits on anything that costs you money
Hosted Supabase Auth rate-limits its own login and signup endpoints, so the gap here is rarely there. It's in the routes you asked for later: the password-reset trigger, the contact form that sends mail, the /api/ route that calls an LLM. Those were written to work once, and nothing in the prompt said "and refuse the two hundredth call in a minute."
To check: pick the endpoint that costs the most per call, and hit it thirty times in a row.
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} " -X POST https://yourapp.com/api/<route>
done
Thirty 200s means there is no limit, and your ceiling is whatever a stranger with a for-loop feels like spending of your money. A 429 somewhere in that line is a pass.
4. The Stripe webhook that believes anyone
Stripe signs every event it sends you precisely so your handler can tell a real event from a made-up one. The unsigned version of the handler works perfectly in testing, which is why assistants keep writing it.
To check: send it a fake event yourself.
curl -i -X POST https://yourapp.com/api/webhooks/stripe \
-H "content-type: application/json" \
-d '{"type":"checkout.session.completed"}'
A 400 is the correct answer. A 200 means anyone who guesses that URL can mark their own order as paid, and the exploit is the command you just ran. If you want to confirm from the code instead, the question to put to your assistant is precise: "does this handler call stripe.webhooks.constructEvent with the signing secret before it touches the database?"
5. What else is readable in the bundle
Everything your build ships to the browser is public. That includes source maps, which turn a minified bundle back into your original files, comments and all. Source maps aren't a hole by themselves, they're a magnifying glass held over whatever holes are already there.
To check: open DevTools, Sources tab, and see whether you can read your own component files with their original names. Then Network tab, find the largest .js file, open it, and search for sk_, secret, _KEY, password, api_key. Whatever comes back is not a risk of leaking. It leaked when you deployed. Rotate it, then move the call server-side.
What the numbers say
Escape scanned 5,600 apps built on Lovable and Supabase and came back with more than 2,000 vulnerabilities and over 400 leaked secrets. Wiz puts roughly one in five organizations using vibe-coding platforms in a state they describe as systemically exposed. Veracode's testing found about 45% of AI-generated code failing standard OWASP checks. Three different methods, three different samples, one shape: this isn't a rare failure mode, it's the default one.
The same list, run by strangers
The Tea app leaked around 72,000 user images, ID photos included, out of a storage bucket that asked nobody for credentials; a researcher who looked at it called it an unlocked front door. Wiz got into Moltbook in about three minutes with a public Supabase key against a database with no row-level security. And a solo founder posting as @leojr94_ watched his Cursor-built SaaS get taken apart in real time and wrote "guys, i'm under attack" while it was happening. None of the three needed a clever exploit or an unpatched CVE. All three were the five checks above, unrun.
Why these are hard to see by scrolling
Look at what the five have in common. Not one of them is visible in a single file. Which side of the network boundary a key sits on, which table is behind which policy, which endpoint trusts its caller: all of it is position, and position is a picture, not a line of code. You can read every file you own and still not have looked at it.
That's the picture Arketo draws. Your own assistant maps the app over MCP and marks gaps like these on the exact component they sit on, free to start, and it never reads your repo. The demo runs without an account, and /examples has finished maps of apps built the same way yours was.
