A founder can now build a working app over a weekend with Lovable, Bolt, Replit or Rocket, and that’s a genuinely good thing. We helped a client build their CRM with Lovable, and the speed was remarkable.
The hard part arrives later. An app that works when you click through it yourself is a long way from one that holds up for hundreds of strangers who don’t follow the happy path. These are the things we check when we take an AI-built app to production as part of our prototype-to-production work. The first one gets the most space, because it’s the gap we find most often and the most serious, so we built a small CRM with that gap and show exactly what a stranger can see.
1. Lock down the data first
Lovable and Bolt apps usually sit on Supabase. The browser talks straight to Supabase’s API using the project URL and the anon key, both visible to anyone who opens developer tools. That’s fine and intended: the API switches to the anon or authenticated role, and row-level security (RLS) policies decide which rows each request may see. When a policy is missing or too loose, the API hands over the data.

To show what this looks like, we set up a small CRM database the way generated apps often have it. Two businesses use it. The contacts table has RLS switched off, and the deals table has RLS on, with the permissive policies that are easy to accept when you’re moving fast. We ran it locally on PostgreSQL behind PostgREST, the API layer Supabase uses, with Supabase’s default table grants.
-- Tables as an AI app builder generated them for a small CRM
create table public.contacts (
id bigint generated by default as identity primary key,
owner_id uuid not null references auth.users default auth.uid(),
name text not null,
email text,
phone text,
company text,
notes text,
created_at timestamptz not null default now()
);
create table public.deals (
id bigint generated by default as identity primary key,
owner_id uuid not null references auth.users default auth.uid(),
contact_id bigint references public.contacts,
title text not null,
value numeric(12, 2),
stage text not null default 'new'
);
alter table public.deals enable row level security;
create policy "Enable read access for all users" on public.deals for select using (true);
create policy "Enable insert for authenticated users only" on public.deals for insert to authenticated with check (true);Here is what anyone can do with nothing but the key that ships in the app:

An audit you can run before every release
Clicking around as yourself won’t find this, because you see the right data either way. The script below checks four things: RLS is on for every table, no policy allows every row, what the API actually returns to a stranger and to an ordinary test user, and whether any secret keys are sitting in the built browser code.
// Pre-launch audit for a Supabase-backed app: database rules, what the public API really returns, and the browser bundle.
// usage: node audit.mjs <dist-folder>
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const API = process.env.API_URL; // e.g. https://<project>.supabase.co/rest/v1
const ANON = process.env.ANON_KEY; // the public key shipped in the app
const USER = { id: process.env.TEST_USER_ID, jwt: process.env.TEST_USER_JWT }; // a normal signed-in test user
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });
let failures = 0;
const report = (ok, msg) => { if (!ok) failures++; console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); };
await db.connect();
// 1. Every table in public must have row-level security switched on
const { rows: tables } = await db.query(`
select c.relname as table, c.relrowsecurity as rls
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' order by 1`);
for (const t of tables) report(t.rls, `${t.table}: row-level security ${t.rls ? 'on' : 'OFF'}`);
// 2. Policies that let everyone through: using (true) on reads, with check (true) on writes
const { rows: open } = await db.query(`
select tablename, policyname, cmd from pg_policies
where schemaname = 'public' and (qual = 'true' or with_check = 'true')`);
for (const p of open) report(false, `${p.tablename}: policy "${p.policyname}" (${p.cmd}) allows every row`);
if (!open.length) report(true, 'no policies allow every row');
// 3. Black-box: what the public API returns to a visitor and to an ordinary user
const get = (table, token) => fetch(`${API}/${table}?select=*`, { headers: { apikey: ANON, Authorization: `Bearer ${token}` } })
.then(async r => (r.ok ? r.json() : []));
for (const { table } of tables) {
const anonRows = await get(table, ANON);
report(anonRows.length === 0, `${table}: anonymous visitor reads ${anonRows.length} row(s)`);
const userRows = await get(table, USER.jwt);
const others = userRows.filter(r => r.owner_id && r.owner_id !== USER.id).length;
report(others === 0, `${table}: test user reads ${others} row(s) belonging to other accounts`);
}
// 4. Secrets in the browser bundle
const secretPatterns = [
['OpenAI key', /sk-(proj-)?[A-Za-z0-9_-]{20,}/g],
['Stripe secret key', /sk_live_[A-Za-z0-9]{20,}/g],
['Supabase service_role key', /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g],
];
const files = fs.readdirSync(process.argv[2], { recursive: true }).filter(f => /\.(js|html|map)$/.test(f));
let found = 0;
for (const f of files) {
const text = fs.readFileSync(path.join(process.argv[2], f), 'utf8');
for (const [label, re] of secretPatterns) {
for (const match of text.match(re) ?? []) {
if (label.startsWith('Supabase')) {
const claims = JSON.parse(Buffer.from(match.split('.')[1], 'base64url').toString());
if (claims.role !== 'service_role') continue; // the anon key is meant to be public
}
found++;
report(false, `${f}: ${label} in browser code (${match.slice(0, 10)}…)`);
}
}
}
if (!found) report(true, `no secret keys in ${files.length} bundle file(s)`);
await db.end();
console.log(`\n${failures ? failures + ' problem(s) to fix before launch' : 'all checks passed'}`);
process.exit(failures ? 1 : 0);
The fix
Switch RLS on everywhere, remove the allow-everything policies, and give each table a policy that ties rows to the signed-in user. Wrapping auth.uid() in a select lets PostgreSQL evaluate it once per query instead of once per row, which Supabase recommends for performance.
-- 1. Switch RLS on for every table
alter table public.contacts enable row level security;
-- 2. Remove the generated "allow everyone" policies
drop policy "Enable read access for all users" on public.deals;
drop policy "Enable insert for authenticated users only" on public.deals;
-- 3. Each account sees and changes only its own rows. Nothing for anon at all.
create policy "own contacts" on public.contacts
for all to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "own deals" on public.deals
for all to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id
and (contact_id is null or contact_id in (select id from public.contacts))); -- only your contacts
-- 4. Policies filter on owner_id, so index it
create index on public.contacts (owner_id);
create index on public.deals (owner_id);The OpenAI call moves out of the browser into a server-side function, such as a Supabase Edge Function (linked in the sources below), which keeps the key in the function’s secrets and only answers signed-in users. The app calls that function instead of OpenAI directly, and the OpenAI account gets a monthly usage limit.


2. Get secret keys out of the browser
Generated apps sometimes call OpenAI, Stripe or other paid APIs straight from the browser, as in the demo above. Move those calls into a server or Edge Function, rotate any key that has ever been in front-end code or a public repository, and put spending limits on paid APIs so one runaway loop can’t run up a bill.
3. Make sign-in boring
- Sign up and confirm the email on a phone, not just on your laptop.
- Reset a forgotten password from start to finish.
- Open an admin page as a normal user.
- Remove a user and check they lose access, including in tabs they already had open.
- Leave the app open overnight and see what happens when the session expires.
4. Keep development away from production
Builders and AI agents make changes quickly, which is exactly why they shouldn’t be pointed at live customer data. There have been public cases of AI agents deleting production databases. Use a separate development project, switch on automated backups in production and restore one once to prove it works, and decide who (or what) is allowed to change production.
5. Payments and email
Verify Stripe webhooks and make sure the same event arriving twice doesn’t charge or fulfill twice. Test failed payments, refunds and cancelled subscriptions, not just the happy checkout. Send transactional email from your own domain with SPF and DKIM set up, or a fair share will end up in spam. The same signature-and-deduplication pattern is shown step by step in our Shopify webhooks tutorial.
6. Test the flows that make you money
You don’t need full test coverage on day one. You need automated tests on the handful of flows that lose you customers when they break: sign-up, the one thing your product exists to do, and checkout. Run them, and the audit above, before every release, including releases made from inside the builder.
7. Watch it once it’s live
Add error tracking so you hear about failures before customers email you, uptime checks on the pages and APIs that matter, cost alerts on hosting, database and AI usage, and logs you can actually search when someone writes in to say “it didn’t work”.
8. Know where the code lives
Lovable syncs to GitHub, and Bolt and Rocket let you export the code. Make sure the repository sits in your company’s account rather than someone’s personal one, and that another developer could clone it, run it and make sense of it. If only the builder can change your app, you don’t fully own it yet.
Stay on the builder, or move off it?
| Situation | What we’d suggest |
|---|---|
| Still finding product fit; most changes are screens and flows | Stay on the builder, with the checklist above done |
| Integrations, background jobs, complex permissions or AI features | Bring developers in alongside the builder, in the same repository |
| Hosting costs, data-location rules or reliability needs outgrow the platform | Move to your own hosting and stack |
We’ve written separate notes on Lovable, Bolt, Replit and Rocket.
Mistakes we’d avoid
Sources
Questions we often hear
Is an app built with an AI app builder safe to launch?
It can be, once someone has reviewed the data access rules, secrets, logins and payments. Generated code works, but it’s rarely reviewed for security by default.
Is it a problem that our Supabase anon key is visible in the browser?
No, it’s designed to be public. The problem is any table without row-level security or with a policy that allows every row, because the anon key can then read it.
Do we need to rewrite the app?
Usually not. Most AI-built apps can be fixed and extended. A rewrite only makes sense when the code can’t be made safe or maintainable.




