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.
-- 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.
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.# 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".
// 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
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.
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.
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.
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());
});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.{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }
]
}
]
}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.
/*
Strict-Transport-Security: max-age=31536000; includeSubDomainsThe 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.
app.use((req, res, next) => {
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
next();
});Add it before your routes and static files so every response carries the header.
Rules > Transform Rules > Modify Response Header > Create rule
When: All incoming requests
Then: Set static header
Name: Strict-Transport-Security
Value: max-age=31536000; includeSubDomainsApplies at the edge, so it works even if your host cannot set headers (GitHub Pages, Lovable hosting).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;Then: sudo nginx -t && sudo systemctl reload nginx
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"Needs mod_headers, which most hosts enable. Place the file in the site root.
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.{
"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 'none'" }
]
}
]
}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.
/*
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 'none'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.
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 'none'");
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.
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 'none'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.
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 'none'" 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.
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 'none'"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.
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.{
"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.
/*
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.
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.
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).
add_header X-Content-Type-Options "nosniff" always;Then: sudo nginx -t && sudo systemctl reload nginx
Header always set X-Content-Type-Options "nosniff"Needs mod_headers, which most hosts enable. Place the file in the site root.
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.{
"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.
/*
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.
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.
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).
add_header Referrer-Policy "strict-origin-when-cross-origin" always;Then: sudo nginx -t && sudo systemctl reload nginx
Header always set Referrer-Policy "strict-origin-when-cross-origin"Needs mod_headers, which most hosts enable. Place the file in the site root.
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