demo-crm.lovable.app
A sample report on a fictional Lovable app, so you can see what a scan looks like before pasting your own address. Everything below is the real output: the findings, the plain-English fixes, and the code to paste.
Supabase table is readable by anyone (RLS off) critical
- Why this matters
- Row Level Security is your only wall between the public anon key and your database. With it off, anyone who views your page source can read (and often write) every record in this table. This is the single most common way vibe-coded apps leak their entire user base.
- How to fix it
- In Supabase, enable Row Level Security on every table and add explicit policies (e.g. a user can only read their own rows). Then re-test. Do not rely on the anon key being 'hidden'; it never is.
Prompt for Lovable Paste into the chat
A security scan found that the Supabase table "customers" can be read by anyone who has the anon key from this app's page source. Add a new Supabase migration that turns on Row Level Security for public."customers" and adds policies so a signed-in user can only read and change their own rows: match auth.uid() against the column on that table that holds the owner's user id. If that table has no such column, restrict it to signed-in users for now and tell me which column should own a row instead of guessing. Do not touch any other table. After the migration has run, open the pages that list this data and check they still work. When it is done, re-scan the app on leftunlocked.com to confirm the fix landed.An AI editor will sometimes change more than you asked. Read what it proposes before accepting it, and re-scan afterwards rather than taking its word.
Supabase SQL editor (Dashboard > SQL)
-- 1. Turn on Row Level Security. With no policies, the anon key can no longer read this table.
alter table public.customers enable row level security;
-- 2. Let signed-in users see and change only their own rows (assumes a user_id column).
create policy "customers: own rows" on public.customers
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- If everyone may READ this table (a public catalog), allow just that instead of step 2:
-- create policy "customers: public read" on public.customers for select using (true);
-- Check every table in one go: "rowsecurity" must be true on all of them.
select tablename, rowsecurity from pg_tables where schemaname = 'public';Run it, then re-scan. If the app stops showing data, the column that links a row to its owner has another name than user_id; change it in the policy, not by turning RLS off.
Secret-sounding settings are built into your public code medium
- Why this matters
- Anything named VITE_, NEXT_PUBLIC_, REACT_APP_ or similar is copied into the JavaScript every visitor downloads. If one of these holds a private key, a service password, or a paid API's key, it is public now and can be used on your bill.
- How to fix it
- Keep secret keys on the server (an API route or serverless function) without the public prefix, and rotate any key that was shipped this way. Keys that are meant for browsers (Maps, Firebase, publishable keys) are fine.
Prompt for Lovable Paste into the chat
A security scan found the secret behind VITE_OPENAI_API_KEY in this app's browser bundle, where every visitor can read it. Move every call that uses it to the server: put it in a Supabase edge function that holds the key as a secret, and have the browser call that endpoint instead. Remove VITE_OPENAI_API_KEY from the client code and from .env. I will rotate the key myself in the vendor's dashboard, so do not worry about the value. Change only what this needs. Do not refactor or "improve" anything else. When it is done, re-scan the app on leftunlocked.com to confirm the fix landed.An AI editor will sometimes change more than you asked. Read what it proposes before accepting it, and re-scan afterwards rather than taking its word.
First, everywhere Read this first
Rotate the key now in the vendor dashboard (it has been public since the build shipped), remove it from the browser code, and move the call behind a server function like the ones below. Only publishable keys belong in the browser.Environment file .env
# Before (copied into the browser bundle by the public prefix):
VITE_OPENAI_API_KEY=sk-...
# After (server only; read it from a function, never from the browser):
OPENAI_API_KEY=sk-...The prefix is the switch: VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_ all mean "ship this to every visitor".
Supabase Edge Function supabase/functions/ask/index.ts
// The key lives in the function's secrets, never in the browser:
// supabase secrets set OPENAI_API_KEY=sk-...
Deno.serve(async (req) => {
const { prompt } = await req.json();
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] }),
});
return new Response(await r.text(), { headers: { "Content-Type": "application/json" } });
});
// In the app, call the function instead of the vendor:
// const { data } = await supabase.functions.invoke("ask", { body: { prompt } });Replace the vendor URL and env name with the vendor's. Deploy with: supabase functions deploy ask
Vercel serverless api/ask.js
export default async function handler(req, res) {
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(req.body),
});
res.status(r.status).json(await r.json());
}
// Browser: fetch("/api/ask", { method: "POST", body: JSON.stringify({...}) })Add OPENAI_API_KEY (no VITE_ or NEXT_PUBLIC_ prefix) in Vercel > Settings > Environment Variables.
Netlify Functions netlify/functions/ask.js
export default async (req) => {
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: await req.text(),
});
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
};
// Browser: fetch("/.netlify/functions/ask", { method: "POST", body: JSON.stringify({...}) })Set OPENAI_API_KEY in Site configuration > Environment variables.
Next.js route handler app/api/ask/route.ts
export async function POST(req: Request) {
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: await req.text(),
});
return new Response(await r.text(), { status: r.status, headers: { "Content-Type": "application/json" } });
}process.env.OPENAI_API_KEY without NEXT_PUBLIC_ stays on the server. Anything NEXT_PUBLIC_ is copied into the browser bundle.
Node / Express server.js
app.post("/api/ask", async (req, res) => {
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(req.body),
});
res.status(r.status).json(await r.json());
});HSTS not set medium
- Why this matters
- Without HSTS a visitor's first request can be downgraded to HTTP and intercepted.
- How to fix it
- Add: Strict-Transport-Security: max-age=31536000. Add includeSubDomains once every subdomain is served over HTTPS.
Lovable hosting Read this first
Lovable hosting does not let you set response headers. Connect the project to GitHub, deploy it on Netlify or Vercel (both free, ten minutes), and use that snippet. Until then, a Cloudflare rule in front of your domain also works.Vercel vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=31536000" }
]
}
]
}Put the file in the project root next to package.json and redeploy. If you already have a vercel.json, add the entry to its "headers" list. This tells browsers to use HTTPS on this host for a year, and they remember it. Add includeSubDomains once every subdomain is served over HTTPS; that part cannot be taken back quickly.
Netlify public/_headers
/*
Strict-Transport-Security: max-age=31536000The file must end up in the folder Netlify publishes (public/ for Vite, out/ or the site root otherwise). Netlify reads it on deploy; no other config needed. This tells browsers to use HTTPS on this host for a year, and they remember it. Add includeSubDomains once every subdomain is served over HTTPS; that part cannot be taken back quickly.
Node / Express server.js
app.use((req, res, next) => {
res.setHeader("Strict-Transport-Security", "max-age=31536000");
next();
});Add it before your routes and static files so every response carries the header. This tells browsers to use HTTPS on this host for a year, and they remember it. Add includeSubDomains once every subdomain is served over HTTPS; that part cannot be taken back quickly.
Cloudflare Dashboard, no code
Rules > Transform Rules > Modify Response Header > Create rule
When: All incoming requests
Then: Set static header
Name: Strict-Transport-Security
Value: max-age=31536000Applies at the edge, so it works even if your host cannot set headers (GitHub Pages, Lovable hosting). This tells browsers to use HTTPS on this host for a year, and they remember it. Add includeSubDomains once every subdomain is served over HTTPS; that part cannot be taken back quickly.
Nginx nginx.conf (inside server { })
add_header Strict-Transport-Security "max-age=31536000" always;Then: sudo nginx -t && sudo systemctl reload nginx This tells browsers to use HTTPS on this host for a year, and they remember it. Add includeSubDomains once every subdomain is served over HTTPS; that part cannot be taken back quickly.
Apache .htaccess
Header always set Strict-Transport-Security "max-age=31536000"Needs mod_headers, which most hosts enable. Place the file in the site root. This tells browsers to use HTTPS on this host for a year, and they remember it. Add includeSubDomains once every subdomain is served over HTTPS; that part cannot be taken back quickly.
No Content-Security-Policy medium
- Why this matters
- CSP is the main defense against cross-site scripting. Without it, an injected script runs with full access to your page and users' sessions.
- How to fix it
- Start with a report-only CSP to see what breaks, then enforce. Even a basic default-src 'self' policy is a large improvement.
Lovable hosting Read this first
Lovable hosting does not let you set response headers. Connect the project to GitHub, deploy it on Netlify or Vercel (both free, ten minutes), and use that snippet. Until then, a Cloudflare rule in front of your domain also works.Vercel vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.googleapis.com https://*.firebaseio.com; frame-ancestors 'self'" }
]
}
]
}Put the file in the project root next to package.json and redeploy. If you already have a vercel.json, add the entry to its "headers" list. A policy this strict can block scripts or styles your app loads from other domains. Ship it as Content-Security-Policy-Report-Only for a day first, watch the browser console for "blocked" messages, add those origins, then rename the header back.
Netlify public/_headers
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.googleapis.com https://*.firebaseio.com; frame-ancestors 'self'The file must end up in the folder Netlify publishes (public/ for Vite, out/ or the site root otherwise). Netlify reads it on deploy; no other config needed. A policy this strict can block scripts or styles your app loads from other domains. Ship it as Content-Security-Policy-Report-Only for a day first, watch the browser console for "blocked" messages, add those origins, then rename the header back.
Node / Express server.js
app.use((req, res, next) => {
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.googleapis.com https://*.firebaseio.com; frame-ancestors 'self'");
next();
});Add it before your routes and static files so every response carries the header. A policy this strict can block scripts or styles your app loads from other domains. Ship it as Content-Security-Policy-Report-Only for a day first, watch the browser console for "blocked" messages, add those origins, then rename the header back.
Cloudflare Dashboard, no code
Rules > Transform Rules > Modify Response Header > Create rule
When: All incoming requests
Then: Set static header
Name: Content-Security-Policy
Value: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.googleapis.com https://*.firebaseio.com; frame-ancestors 'self'Applies at the edge, so it works even if your host cannot set headers (GitHub Pages, Lovable hosting). A policy this strict can block scripts or styles your app loads from other domains. Ship it as Content-Security-Policy-Report-Only for a day first, watch the browser console for "blocked" messages, add those origins, then rename the header back.
Nginx nginx.conf (inside server { })
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.googleapis.com https://*.firebaseio.com; frame-ancestors 'self'" always;Then: sudo nginx -t && sudo systemctl reload nginx A policy this strict can block scripts or styles your app loads from other domains. Ship it as Content-Security-Policy-Report-Only for a day first, watch the browser console for "blocked" messages, add those origins, then rename the header back.
Apache .htaccess
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.googleapis.com https://*.firebaseio.com; frame-ancestors 'self'"Needs mod_headers, which most hosts enable. Place the file in the site root. A policy this strict can block scripts or styles your app loads from other domains. Ship it as Content-Security-Policy-Report-Only for a day first, watch the browser console for "blocked" messages, add those origins, then rename the header back.
X-Content-Type-Options not set low
- Why this matters
- Browsers may guess (sniff) content types, which can turn an uploaded file into executable script.
- How to fix it
- Add: X-Content-Type-Options: nosniff
Lovable hosting Read this first
Lovable hosting does not let you set response headers. Connect the project to GitHub, deploy it on Netlify or Vercel (both free, ten minutes), and use that snippet. Until then, a Cloudflare rule in front of your domain also works.Vercel vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" }
]
}
]
}Put the file in the project root next to package.json and redeploy. If you already have a vercel.json, add the entry to its "headers" list.
Netlify public/_headers
/*
X-Content-Type-Options: nosniffThe file must end up in the folder Netlify publishes (public/ for Vite, out/ or the site root otherwise). Netlify reads it on deploy; no other config needed.
Node / Express server.js
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
next();
});Add it before your routes and static files so every response carries the header.
Cloudflare Dashboard, no code
Rules > Transform Rules > Modify Response Header > Create rule
When: All incoming requests
Then: Set static header
Name: X-Content-Type-Options
Value: nosniffApplies at the edge, so it works even if your host cannot set headers (GitHub Pages, Lovable hosting).
Nginx nginx.conf (inside server { })
add_header X-Content-Type-Options "nosniff" always;Then: sudo nginx -t && sudo systemctl reload nginx
Apache .htaccess
Header always set X-Content-Type-Options "nosniff"Needs mod_headers, which most hosts enable. Place the file in the site root.
Referrer-Policy not set low
- Why this matters
- Full URLs (which can contain tokens) may leak to third-party sites via the Referer header.
- How to fix it
- Add: Referrer-Policy: strict-origin-when-cross-origin
Lovable hosting Read this first
Lovable hosting does not let you set response headers. Connect the project to GitHub, deploy it on Netlify or Vercel (both free, ten minutes), and use that snippet. Until then, a Cloudflare rule in front of your domain also works.Vercel vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}Put the file in the project root next to package.json and redeploy. If you already have a vercel.json, add the entry to its "headers" list.
Netlify public/_headers
/*
Referrer-Policy: strict-origin-when-cross-originThe file must end up in the folder Netlify publishes (public/ for Vite, out/ or the site root otherwise). Netlify reads it on deploy; no other config needed.
Node / Express server.js
app.use((req, res, next) => {
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
next();
});Add it before your routes and static files so every response carries the header.
Cloudflare Dashboard, no code
Rules > Transform Rules > Modify Response Header > Create rule
When: All incoming requests
Then: Set static header
Name: Referrer-Policy
Value: strict-origin-when-cross-originApplies at the edge, so it works even if your host cannot set headers (GitHub Pages, Lovable hosting).
Nginx nginx.conf (inside server { })
add_header Referrer-Policy "strict-origin-when-cross-origin" always;Then: sudo nginx -t && sudo systemctl reload nginx
Apache .htaccess
Header always set Referrer-Policy "strict-origin-when-cross-origin"Needs mod_headers, which most hosts enable. Place the file in the site root.
Valid HTTPS certificate (61 days left) pass
- Why this matters
- Traffic is encrypted and the certificate is trusted.
Clickjacking protection present pass
No common sensitive files exposed pass
No obvious secret keys in shipped code pass
Plain HTTP redirects to HTTPS pass
Is your app leaving anything unlocked?
Paste your URL and get the same report for your own app in about ten seconds. Read-only, free, written in plain English.
Scan your app free