Free checklist

Eight checks before real users arrive.

For founders and small teams who built an app with Lovable, Bolt, Replit or Supabase and want to know whether their users' data is actually locked down. Each item has a two-minute self-check you can run without a security background.

Run these only against an app you own or have written permission to test, and use test accounts, not real customers. Most steps use Supabase terms; the same ideas apply to Firebase rules or your own API.

The checklist

Start at the top.

The first two decide who can read your data. The rest close the gaps around them.

Check 01

Row Level Security is off on a table.

The problem. A table in your database has Row Level Security (RLS) turned off.

Why it matters. The Supabase anon key (called the publishable key in new projects) ships in your frontend and is public by design. With RLS off, that key is all a stranger needs to read, and often change, every row in the table.

Check it yourself (2 minutes)

  1. Open your project in the Supabase dashboard and go to the Table Editor.
  2. Look at every table in the public schema. Any table without RLS enabled (Supabase marks these as unrestricted) is readable by anyone with the key.
  3. Or run this in the SQL Editor. Every row it returns is a table with RLS off:
    select tablename
    from pg_tables
    where schemaname = 'public'
      and rowsecurity = false;

Fix

Enable RLS on every table, then write policies (check 02). With RLS on and no policies, the app can no longer read the table, so do this on a staging copy first.

alter table public.profiles enable row level security;

Check 02

RLS is on, but the policy is true.

The problem. A policy such as using (true) lets every request through, which is the same as having no RLS at all.

Why it matters. The dashboard shows RLS as enabled, so the table looks protected. Policies only protect data when they compare the row to the signed-in user, for example using (user_id = auth.uid()).

Check it yourself (2 minutes)

  1. List your policies in the SQL Editor and look for true in the qual or with_check columns:
    select tablename, policyname, cmd, qual, with_check
    from pg_policies
    where schemaname = 'public';
  2. Create two test users, A and B, and add a row that belongs to B.
  3. Sign in as A, copy A's access token from your browser's developer tools, and ask for B's row:
    curl "https://<project>.supabase.co/rest/v1/notes?user_id=eq.<user-b-id>" \
      -H "apikey: <anon-or-publishable-key>" \
      -H "Authorization: Bearer <user-a-access-token>"
  4. An empty list [] is the right answer. If B's row comes back, A can read other people's data.

Fix

Rewrite the policies table by table, one each for select, insert, update and delete, so every one compares to auth.uid():

create policy "read own" on public.notes
  for select to authenticated using (user_id = auth.uid());
create policy "add own" on public.notes
  for insert to authenticated with check (user_id = auth.uid());
create policy "edit own" on public.notes
  for update to authenticated
  using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy "delete own" on public.notes
  for delete to authenticated using (user_id = auth.uid());

Check 03

A secret is in the frontend.

The problem. A key that should only live on a server, such as the Supabase service role key, a Stripe live secret key or an AI model API key, is in the code that ships to the browser.

Why it matters. Anyone can read your frontend bundle. The service role key skips RLS entirely, and a model API key lets a stranger run up your bill.

Check it yourself (2 minutes)

  1. Search the repo for key names and prefixes:
    git grep -nE "service_role|sb_secret_|sk_live_|OPENAI_API_KEY|ANTHROPIC_API_KEY"
  2. Build the app and run the same search on the build output (often dist or .next), or search your live site in the browser's developer tools under Sources.
  3. Any variable starting with VITE_ or NEXT_PUBLIC_ is bundled into the frontend on purpose. None of them should hold a secret.

Fix

Move the call that needs the secret into an edge function or a server route, and keep the key in that server's environment variables. If a key was ever committed or deployed, rotate it with the provider: deleting it from the code is not enough, because it stays in git history and in old builds.

Check 04

Users can make themselves admin.

The problem. A role or is_admin column sits on a table, such as profiles, that users are allowed to update.

Why it matters. A policy that lets users edit their own row usually lets them edit every column in it, including the one that decides who is an admin.

Check it yourself (2 minutes)

  1. Sign in as a normal test user and copy the access token, as in check 02.
  2. Try to change your own role:
    curl -X PATCH "https://<project>.supabase.co/rest/v1/profiles?id=eq.<your-user-id>" \
      -H "apikey: <anon-or-publishable-key>" \
      -H "Authorization: Bearer <your-access-token>" \
      -H "Content-Type: application/json" \
      -H "Prefer: return=representation" \
      -d '{"role":"admin"}'
  3. If the response shows your row with "role":"admin", any user can promote themselves.

Fix

Move roles to their own table that users can read but not write, or stop users updating that column by granting update only on the columns they may change:

revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;

Check 05

Edge functions do not check who is calling.

The problem. An edge function trusts a user_id sent in the request body, or runs without checking for a signed-in user.

Why it matters. Edge functions often use the service role key, so RLS does not apply inside them. Whatever user the function believes it is serving, it can read and change that user's data.

Check it yourself (2 minutes)

  1. Call the function with no Authorization header:
    curl -i -X POST "https://<project>.supabase.co/functions/v1/<function-name>" \
      -H "Content-Type: application/json" -d '{}'
  2. A 401 is the right answer. Anything that does work is a problem.
  3. Then call it with your own access token and another test user's user_id in the body. If it acts for that other user, it trusts the body.

Fix

Keep JWT verification on for the function, and read the user from the verified token, never from the body. The anon key is itself a valid token, so also confirm a real user came back:

const token = req.headers.get("Authorization")?.replace("Bearer ", "");
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return new Response("Unauthorized", { status: 401 });
// use user.id from here on, never body.user_id

Check 06

Public storage buckets hold private files.

The problem. Uploads such as invoices, ID photos or private documents sit in a storage bucket marked public.

Why it matters. Files in a public bucket can be opened by anyone with the URL, without signing in, and URLs leak through emails, logs and shared screenshots.

Check it yourself (2 minutes)

  1. In the Supabase dashboard, open Storage and note which buckets are marked public.
  2. Copy the URL of a file a user uploaded and open it in a private browser window where you are not signed in.
  3. If a file that should be private loads, the bucket is exposing it.

Fix

Make the bucket private, add storage policies so users can reach only their own files, and serve files through short-lived signed URLs:

const { data } = await supabase.storage
  .from("documents")
  .createSignedUrl(path, 60); // valid for 60 seconds

Check 07

Payment webhooks skip signature checks.

The problem. The endpoint that receives payment events from Stripe, or another provider, accepts any request without checking it came from the provider.

Why it matters. Anyone who finds the URL can send a fake "payment succeeded" event and unlock paid features or mark orders as paid.

Check it yourself (2 minutes)

  1. Find your webhook URL in the Stripe dashboard under Developers, Webhooks.
  2. Send it an empty, unsigned request:
    curl -i -X POST "https://<your-app>/<webhook-path>" \
      -H "Content-Type: application/json" -d '{}'
  3. You want a 400 that rejects the missing signature. Anything else, including a 200 or a 500, means the endpoint is not checking.

Fix

Verify the signature on the raw request body with the webhook secret before doing anything else, using constructEvent (or constructEventAsync in edge functions) or your provider's equivalent:

const event = stripe.webhooks.constructEvent(
  rawBody, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET
); // throws on a bad signature: return 400

Bonus

Run your platform's security scan, and know its limits.

The problem. Built-in scanners, such as Lovable's security scan or Supabase's Security Advisor, are easy to run and easy to over-trust.

Why it matters. A scanner can tell you RLS is on. It cannot tell you whether user A should see user B's row, because that depends on what your app is for.

Check it yourself (2 minutes)

  1. Run the scan your platform offers. In Supabase, open Advisors and then Security Advisor.
  2. Fix every error and read every warning.
  3. Then run checks 02, 04 and 05 by hand. A clean scan does not cover them.

Fix

Treat the scan as a floor, not a sign-off. Re-run it after every schema change.

Ship Safe Check

Want a second pair of eyes?

Ship Safe Check, $290: a hands-on check of all eight, with your written authorization, in 3 business days.