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.

F

Security score 30/100

https://demo-crm.lovable.app · scanned just now · 3 pages, 4 scripts inspected · 6.1s
1 critical3 medium2 low5 pass
Supabase table is readable by anyone (RLS off) critical
Anon key + open REST API: table "customers" returned a row count of ~1,284 without authentication. (No rows were read or stored; count only.)
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.
Paste-ready fix
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
1 variable name with a public prefix: VITE_OPENAI_API_KEY. Names only; values not shown.
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.
Paste-ready fix
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
Missing on https://demo-crm.lovable.app
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; includeSubDomains
Paste-ready fix
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; 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.

Netlify public/_headers
/*
  Strict-Transport-Security: max-age=31536000; includeSubDomains

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.

Node / Express server.js
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.

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=31536000; includeSubDomains

Applies at the edge, so it works even if your host cannot set headers (GitHub Pages, Lovable hosting).

Nginx nginx.conf (inside server { })
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Then: sudo nginx -t && sudo systemctl reload nginx

Apache .htaccess
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

Needs mod_headers, which most hosts enable. Place the file in the site root.

No Content-Security-Policy medium
Missing on https://demo-crm.lovable.app
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.
Paste-ready fix
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 '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.

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 '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.

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 '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.

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 '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.

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 '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.

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 '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.

X-Content-Type-Options not set low
Missing on https://demo-crm.lovable.app
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
Paste-ready fix
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: nosniff

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.

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: nosniff

Applies 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
Missing on https://demo-crm.lovable.app
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
Paste-ready fix
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-origin

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.

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-origin

Applies 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
TLSv1.3, valid_to Nov 6 12:00:00 2026 GMT
Why this matters
Traffic is encrypted and the certificate is trusted.
Clickjacking protection present pass
x-frame-options: SAMEORIGIN
No common sensitive files exposed pass
Checked .env variants, .npmrc, .git, .DS_Store, config.json, and folder listings
No obvious secret keys in shipped code pass
Scanned 3 page(s) and 4 script file(s)
Plain HTTP redirects to HTTPS pass
http://demo-crm.lovable.app/ answered 301 pointing at https://demo-crm.lovable.app/

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