Watson Standard
Security self-check · for AI-built apps Written by Bryce Watson

← Back to the main page

Eleven security checks for an app you built with Lovable, Bolt, Replit or Cursor

These are the checks I'd run first on an app built with an AI app builder, written so you can run them yourself without a security background. Most take a few minutes in your Supabase dashboard or your code on GitHub, and each one says what to do if it fails.

They come from reading real apps. In September 2026 I read the public code of 12 apps built with Lovable, Cursor and Replit, and 11 of them had at least one problem I'd rate High or Critical. None were built with Bolt, but none of these checks depend on which builder you used. That's a small, hand-picked sample, so read it as "these mistakes are real", not as a measure of how common they are. Each check below says how many of those 12 apps had that problem.

The pattern behind most of them

Your app's screens aren't the only way in. Anyone can copy the public key out of your web page and talk to your database and server functions directly, skipping every button, login screen and hidden admin page. So the question for each check is the same: does the server or the database enforce the rule, or only the browser?

Updated September 28, 2026

Before you start

Run these yourself in 10 minutes

Before the eleven checks below, four quick ones. They use tools you already have, and they catch a lot.

  1. Supabase Security Advisor. In your Supabase dashboard, open Advisors, then Security Advisor. Fix everything marked Error first, starting with any table that has row-level security turned off. Each item links to Supabase's own explanation of the fix. (Supabase's guide)
  2. GitHub secret scanning and push protection. In your repository on GitHub, open Settings, then Advanced Security (Code security on some repositories), and turn on Secret scanning and Push protection. Secret scanning warns you about keys already in the code and its history. Push protection blocks new ones before they're pushed. Both are free on public repositories. Private repositories on a personal account don't get them; for those, run Gitleaks yourself (check 9 shows the same idea with plain git). (GitHub's guide)
  3. Lovable's security scan. If you built with Lovable, run its security scan before you publish, and read every warning rather than just the count. It's a good first list. It can't tell whether a rule matches what your app should allow, so pair it with checks 1 to 4 below.
  4. Replace any key that leaked. If a key ever appeared in your code, a commit, a screenshot or a chat, treat it as public, even if you deleted it:
    1. Create a new key at the provider (Supabase, Stripe, OpenAI and so on).
    2. Put the new key wherever the app reads it (your server function secrets or your host's environment settings) and redeploy.
    3. Turn off or delete the old key at the provider. This is the step that closes the hole.
    4. Check the provider's usage or logs for anything you don't recognize.
    For Supabase's server key: if yours is the older kind (a long key starting eyJ), create a new secret key and a new publishable key in the API key settings, switch your server code to the secret key and your front end to the publishable key, redeploy and confirm the app still works, and only then turn off the legacy keys. Turning them off also turns off the old public key, and edge functions that read SUPABASE_SERVICE_ROLE_KEY still get the old server key, so update those first. (Supabase's guide to API keys)

Worried about something? Send me the repo for a free check. With your OK, I'll run the standard tools on it, take a short look myself and reply privately.

1 Can a stranger read or change your tables?

Seen in 2 of the 12 apps

Supabase protects each table with row-level security: rules that say which rows each visitor can see or change. If the rules are off, or say true (meaning "everyone"), the public key opens the whole table.

How to check

  1. In the Supabase dashboard, open the SQL Editor and run this. Any table showing false isn't protected: row-level security is off, so none of its rules are enforced.
    select tablename, rowsecurity
    from pg_tables where schemaname = 'public';
  2. Then run this to list the rules themselves:
    select tablename, policyname, roles, cmd, qual, with_check
    from pg_policies where schemaname = 'public';
    Look for rows where qual or with_check is just true (or only auth.uid() is not null) and roles includes anon, authenticated or public. authenticated means anyone who has signed up, and sign-up is usually open, so that rule lets a stranger in too. On a table holding anything private, that's a fail.
  3. Views skip these rules by default. If you have any in the public schema, the Security Advisor flags them as an Error; follow its fix.
  4. Watch for public pages that look something up by ID without a login, like an order tracker. A rule can't say "only if they asked by ID", so if strangers can read one row, they can read every row.
  5. Supabase's Security Advisor, in the Advisors section of the dashboard, flags tables with the rules turned off. It's a good second look.
  6. If your app doesn't use Supabase (some Replit apps have their own database), check that each table has a user column and every query filters by the signed-in user.

If it fails

Turn row-level security on for every table, and rewrite each rule around the owner, for example auth.uid() = user_id. For a public lookup, use a database function that takes an unguessable ID and returns only what the page shows. Then sign up a second test account and confirm it can't see or change the first account's data.

2 Do your database functions check who's calling?

Seen in 3 of the 12 apps

A database function marked SECURITY DEFINER runs with the database owner's rights, so your table rules don't apply inside it. Supabase makes every function in the public schema callable from the web, and by default anyone can call it. Functions like this often change credits, balances or stock levels.

How to check

  1. In the SQL Editor, run this. It lists every such function and whether strangers or any signed-in user can call it.
    select p.proname as function_name,
      has_function_privilege('anon', p.oid, 'execute') as strangers_can_run,
      has_function_privilege('authenticated', p.oid, 'execute') as any_user_can_run
    from pg_proc p join pg_namespace n on n.oid = p.pronamespace
    where n.nspname = 'public' and p.prosecdef;
  2. For each one that shows true, find it in your code (search the supabase/migrations folder for its name) and check whether it tests auth.uid() before doing anything. Trigger functions, like handle_new_user, show up here too; the web can't call those directly, so you can skip them. A pass looks like where user_id = auth.uid() on every row it touches, or a first line such as if auth.uid() is null then raise exception 'not signed in'; end if;.

If it fails

First, check whether any table rule calls the function: search your migrations for its name inside a create policy. If one does, leave it callable, because revoking it breaks that rule with a permission error. Small helpers such as has_role(user_id, role) that only answer yes or no are normal there.

Otherwise, if only your server should call it, take it away from everyone else:

revoke execute on function public.your_function from public, anon, authenticated;

If users call it directly, have it read the caller from auth.uid() instead of trusting a user ID passed in.

3 Does every server function check who's calling?

Seen in 6 of the 12 apps

Edge functions and API routes are public web addresses. A login screen in front of the page that uses them doesn't protect them. This was the most common problem I found.

How to check

  1. Open supabase/config.toml and look for verify_jwt = false. Each function listed that way accepts calls from anyone.
  2. Leaving that setting on isn't enough by itself: the public key passes it. So open each function (supabase/functions/<name>/index.ts, or api/, app/api/ or server/ in other setups) and look near the top for a caller check: auth.getUser(, auth.getClaims( or a helper your code uses to read the signed-in user, a check of a secret header for scheduled jobs, or a signature check for payment webhooks.
  3. No check at all is a fail. It's urgent if the function also uses the server key.

If it fails

Start every function with the check that fits it: a signed-in user for user actions, an admin check for admin actions (check 4), a shared secret for scheduled jobs, and the provider's signature check for webhooks.

4 Do admin actions check for an admin on the server?

Seen in 3 of the 12 apps, plus 2 where the browser decided who was signed in

Hiding the admin page, or saving "admin: yes" in the browser, doesn't stop anyone calling the functions behind the admin buttons.

How to check

  1. Find every function that does an admin job (resetting passwords, changing roles, refilling credits, listing or deleting other people's records). Check that it looks up the caller's role on the server, from a table users can't edit themselves.
  2. Search your front-end code for localStorage.setItem. If it saves something like "admin", "verified" or "signed in", and your server trusts that instead of a real session, that's a fail.
  3. Check that no function takes the user's email or ID from the request and acts on it without a signed-in session.

If it fails

Use real sign-in sessions (Supabase Auth, which also does email codes), take the user's identity only from the session on the server, and check the admin role on the server in every admin function.

5 Does the server set prices, discounts, paid status and credits?

Seen in 3 of the 12 apps

If the browser sends the price, the total, a discount or "paid", anyone can change it before it's sent.

How to check

  1. Find the code that creates an order. If it saves a total, a line price or a payment status that came from the browser, that's a fail.
  2. Search your front-end code for writes (insert, update, upsert) to tables about orders, discount codes, credits or balances.
  3. If you sell credits, check that the server takes a credit before doing the paid work, rather than trusting the browser to report what it used.

If it fails

Have the server look up each price, work out the total, apply only discounts it created, and mark an order paid only after your payment provider confirms it.

6 Can your email or text sending be pointed at anyone?

Seen in 3 of the 12 apps

A function that sends mail or texts where the caller picks the recipient and the message is an open relay. Anyone can send phishing from your own verified domain, run up your bill and get your account suspended.

How to check

  1. Find every function that calls your email or SMS provider.
  2. See where the recipient, subject and message come from. If any of them come from the request and there's no caller check, that's a fail.

If it fails

Take only an ID from the request, look up the recipient on the server, build the message from a fixed template, and add a caller check and a rate limit.

7 Can a stranger use your AI account?

Seen in 2 of the 12 apps

A function that passes requests to OpenAI, Gemini, Anthropic or another AI provider with your key, without checking the caller, is a free AI account for anyone who finds it, billed to you.

How to check

  1. Find every function that calls an AI provider.
  2. Check that it requires a signed-in user, takes a credit before the call if you charge for usage, caps how much text it accepts, and doesn't let the caller choose any model they like.
  3. Check that your provider account has a monthly spending limit set.

If it fails

Add the user check, the credit check, a size limit and a list of allowed models, and set the spending limit today.

8 Is a secret key in your front-end code?

Seen in 1 of the 12 apps

Anything whose name starts VITE_ (Vite, common in Lovable and Bolt apps) or NEXT_PUBLIC_ (Next.js) is built into the JavaScript every visitor downloads. That's fine for the Supabase public key and a payment provider's publishable key. A few others are meant to be public too, like a maps key restricted to your site, so treat anything else as needing a look.

How to check

  1. Search your code and your .env files for VITE_ and NEXT_PUBLIC_. Any name containing KEY, SECRET, TOKEN or PASSWORD, other than the two above, needs a look.
  2. Open your live site, open the browser's developer tools, and search all loaded files for sb_secret_, sk_live_, sk-proj-, sk-ant- and eyJhbGciOi. An older Supabase server key is a long string starting eyJhbGciOi, and the word service_role never appears in it as plain text, so compare every eyJhbGciOi string you find with the public key in your dashboard. If it isn't the public key, treat it as the server key. (I didn't find the server key in any front end in my sample, but it's worth the minute.)

If it fails

Move that call into a server function, delete the variable, and replace the key with a new one at the provider. Treat the old key as public.

9 Is a secret anywhere in your git history?

Seen in 4 of the 12 apps

Deleting a .env file or a pasted key doesn't remove it. Every earlier version of your code stays in the repository, and for a public repo anyone can read it. In the apps I read, history held database server keys, AI keys and email keys, and in some cases nothing showed the key had been replaced afterwards.

How to check

  1. In a terminal inside your project, list every settings file ever committed, on every branch:
    git log --all --name-only --format= | grep -E '(^|/)\.env' | sort -u
    Only example files (.env.example) are fine.
  2. Search every past version for common key shapes:
    git log --all -p | grep -nE 'eyJhbGciOi|sb_secret_|sk_live_|sk_test_|sk-proj-|sk-ant-|whsec_|AIza|GOCSPX-'
    A line that only names a setting is fine. A line with a long random value is a fail, apart from the Supabase public key (next step). If you have Gitleaks installed, gitleaks git -v checks for many more key shapes.
  3. If you find a Supabase key starting eyJ, compare it with the public key in your dashboard's API key settings. If it isn't the public key, treat it as the server key. Don't paste it into an online decoder.
  4. Turn on GitHub's secret scanning and push protection in your repository's security settings. It's free for public repositories.

If it fails

Replace every exposed key first. For Supabase, create new API keys in the API key settings, update everywhere the app uses them and confirm it still works, then turn off the old ones. Then add .env* (except the example) to .gitignore. Cleaning the history is optional tidying: replacing the key is what closes the hole.

10 Did your AI chat logs get committed?

Seen in 1 of the 12 apps

Some tools save your AI conversations as files in the project, for example a .specstory folder. Those logs capture whatever the AI printed, including your settings file and its keys.

How to check

git log --all --name-only --format= | grep -E '\.specstory|\.aider' | sort -u

If anything shows up, run the key search from check 9 over it, and look at every branch, not just the main one.

If it fails

Replace any key the logs contain, add the folder to .gitignore, and delete or clean the branches that still have it.

11 Does any page show text from the web address?

Seen in 1 of the 12 apps, plus a similar case in my sample review

Login callback pages (where "sign in with GitHub or Google" sends people back) often show an error message taken from the web address. If that text lands in the page without being made safe, one crafted link can run someone else's script on your domain, with your signed-in session.

How to check

  1. Search your code for error_description, innerHTML and dangerouslySetInnerHTML, and for JSON.stringify inside a <script> tag. Each hit that uses text from the web address or from the database needs a look.
  2. Check whether your host sends a Content-Security-Policy header (in vercel.json, netlify.toml or a _headers file). It limits the damage if something slips through.

If it fails

Show a fixed error message instead of echoing the text, or escape it for where it lands. Add a Content-Security-Policy.

What this doesn't cover

A self-check isn't a review. It covers the mistakes I've seen most often in a small sample, not everything that can go wrong, and passing all eleven doesn't mean your app is secure. It also can't see settings that live only in your dashboards, or problems in the libraries your app uses.

If you'd like another pair of eyes, send me the repo for a free security check: I run the standard tools with your OK, take a short look myself and reply privately. For the full job, I do paid launch reviews (details on the main page).