Fixing the finding fixes one line. Changing the instruction that produced it fixes every line the next prompt writes, which is the only version of this that scales when the code is arriving faster than it can be read.
criticalVC001 · 17 of 25 projects · 154 occurrences
Keys that end up in the browser bundle
Hardcoded API Key or Secret
Why this workflow produces it
A Lovable project is a Vite single-page app. There is no server in it, so there is nowhere in the generated project for a secret to live — every string in the repository is compiled into JavaScript that anyone can download and read.
The moment you add a database — and Supabase is the usual answer — the project gains a client config with a key written inline. The codebase now demonstrates that pattern before you have asked for your first real feature. When the next prompt is "add Stripe" or "send an email when someone signs up", the fastest completion is the one that follows the file already in front of it, and a provider key lands beside the one that was always meant to be public.
Moving it to an environment variable does not help by itself. Vite inlines every VITE_-prefixed variable into the bundle at build time, so a secret in .env is a secret on your CDN.
The change that prevents it
Split credentials into two buckets before you prompt, and say which is which. The Supabase anon key is publishable by design — the control protecting that data is row-level security, not secrecy. Everything else (a service-role key, a Stripe secret key, any provider key that can spend money or read another user's data) must never reach the client at all: it belongs in a Supabase Edge Function or another server, with the browser calling that endpoint.
Paste this into your project instructions, or into the prompt itself:
Never put a secret in client code or in a VITE_-prefixed
env var — Vite inlines both into the browser bundle.
Any key that can spend money, send mail, or read another
user's data goes in a Supabase Edge Function and is read
from Deno.env.get() there. The browser calls the function.
The Supabase anon key is the only key allowed in the client,
and every table it can reach must have RLS enabled.
The scan that verifies it
Run a scan and confirm the count goes to zero. If a hit is the anon key or another value that is genuinely meant to be published, mark that line reviewed rather than deleting the rule.
npx xploitscan scan . -f json \
| jq '[.findings[] | select(.rule == "VC001")] | length'
Honest caveat. Some of these are the Supabase anon key, which is publishable. That is exactly why the fix is to sort your keys rather than to silence the rule: the same check is what catches the service-role key sitting three lines below it.
highENTROPY · 11 of 25 projects · 83 occurrences
Secrets that do not look like anyone's secrets
High-Entropy String Detected (Possible Secret)
Why this workflow produces it
The secrets rule above knows the shape of the common providers. This check is the backstop for everything else: a webhook signing secret, an internal token, a private key pasted into a constant — strings with no recognisable prefix that are nonetheless clearly not prose.
It matters most in exactly this workflow, because a project assembled by prompting accumulates integrations faster than anyone writes down which of their values are safe to publish.
The change that prevents it
Same rule as above: decide what is publishable before the key is pasted anywhere. If a value has to be in the client, it must be one that is safe to hand to a stranger.
The scan that verifies it
Scan, then triage the list once. Anything genuinely public — a published verification token, a hash, encoded data — gets an inline ENTROPY-OK comment with the reason, and never fires again.
npx xploitscan scan . -f json \
| jq '[.findings[] | select(.rule == "ENTROPY")] | length'
Honest caveat. High-entropy strings also appear in vendored component libraries, where they are not secrets at all. This page counts only findings in application code; the copied-in scaffold is broken out separately below.
highVC020 · 25 of 25 projects · 80 occurrences
The response headers a static host does not set for you
Missing Content Security Policy (CSP)
Why this workflow produces it
This is really three findings in the table above — the missing content-security policy, the missing frame-ancestors control, and the missing content-type and referrer policies. They travel together because they have one cause.
A Lovable project is a static index.html served by a host. All of these are response headers, and a static file does not carry a response header. Something in front of it has to add them, and the default deployment target usually adds none.
Nothing looks broken as a result, because none of these headers change how a working app behaves. They only change what happens during an attack, which is why they survive every round of testing you do before launch.
The change that prevents it
Add them where the host reads them: a _headers file on Netlify or Cloudflare Pages, the headers array in vercel.json on Vercel. The content-security policy is the one worth real time and the one most likely to break something, so run it in report-only mode first and read the reports before you enforce it. The rest are close to free.
The scan that verifies it
Scan for all three, then check the live response and confirm the headers actually arrive: curl -sI https://your-app.example. The scan reads your repository; only the response proves a header is really being sent.
npx xploitscan scan . -f json \
| jq '[.findings[] | select(.rule == "VC020" or .rule == "VC027" or .rule == "VC056")] | length'
Honest caveat. This cluster fired on every project in the sample, which says more about the scaffold than about any developer — and part of it is a measurement limit on our side. The scanner reads your repository, so a host that already injects these headers still reads as missing. Check the live response before you spend an afternoon on it.
highVC046 · 15 of 25 projects · 24 occurrences
Sign-in paths with no visible session rotation
Session Fixation Risk
Why this workflow produces it
The sign-in screen is usually one of the first things generated, and it is written as a form handler: call the auth method, then navigate. Whether the session identifier the browser was carrying before sign-in is replaced afterwards is not something the generated code ever addresses, because nothing in the happy path depends on it.
When the identifier is not replaced, a value an attacker managed to plant in the browser beforehand survives into the authenticated session.
The change that prevents it
State the requirement in the prompt when you ask for auth, and again if you ever hand-roll a session rather than delegating to the SDK.
Paste this into your project instructions, or into the prompt itself:
On successful sign-in, issue a brand-new session identifier
and discard the pre-login one. Never carry a session value
that existed before authentication into the signed-in state.
The scan that verifies it
Scan, then read the sign-in path once with this specific question in mind. If your auth SDK already issues a fresh session on sign-in, mark the finding reviewed with an inline comment giving that reason — a documented decision, not a deleted rule.
npx xploitscan scan . -f json \
| jq '[.findings[] | select(.rule == "VC046")] | length'
Honest caveat. This check reads one file at a time and cannot follow session handling into an SDK. In a project that delegates sessions entirely to a hosted auth provider, a share of these are ours to answer for rather than yours. It is on this list because the sign-in path is worth five minutes of your reading, not because every hit is a confirmed bug.
mediumVC037 · 12 of 25 projects · 572 occurrences
Error handlers that hand back the internals
Stack Traces Exposed in API Responses
Why this workflow produces it
Read the two numbers in the header of this section together. It reached a minority of projects and still fired hundreds of times, which is what a copy-pasted shape looks like: once per endpoint, in the projects that have endpoints at all.
The generated catch block is almost always the same three lines: catch the error, JSON.stringify a body containing error.message, return a 500. It is a completely reasonable thing to write while you are building, and nothing about the app misbehaves afterwards, so it never comes up again.
What ships to a caller is your ORM's error text, your table and column names, and often the query that failed — a free map of your schema for anyone who sends a deliberately malformed request.
The change that prevents it
Ask for the error contract explicitly, once, and it applies to every endpoint generated afterwards. Log the real error server-side, return a generic message and a correlation id to the caller.
Paste this into your project instructions, or into the prompt itself:
Every server error response returns a generic message and a
random correlation id, never error.message and never a stack.
Log the full error with that same id server-side so it can
still be traced. Apply this to every handler you generate.
The scan that verifies it
Scan, then confirm by hand: send a request that is guaranteed to fail and read the response body. It should tell you nothing you did not already know about your database.
npx xploitscan scan . -f json \
| jq '[.findings[] | select(.rule == "VC037")] | length'