AI app builders

Taking a Lovable, Bolt or Replit app to production: a pre-launch checklist, with an audit script

Taking a Lovable, Bolt or Replit app to production: a pre-launch checklist, with an audit script

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.

Diagram: the app ships the anon key; anyone can call the Supabase API with it; a contacts table without RLS and a deals table with a using (true) policy return every row
The anon key isn’t the leak. A table without the right policy is.

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.

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

Terminal: a curl request with the public anon key returns contacts with names, emails, phone numbers and notes; a second request counts 25 rows, every contact of both businesses
No sign-in. Every contact of both businesses, with phone numbers and private notes.

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.

javascript
// 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);
Point it at a staging project with two test accounts. It needs a database connection for the catalog checks, the anon key, and a signed-in test user’s token.
Audit output: FAIL for contacts RLS off, two policies that allow every row, anonymous reads of 25 contacts and 21 deals, a test user reading 11 contacts and 8 deals from another account, and an OpenAI key in the bundle
The real output on the demo app: 8 problems, including an OpenAI key in the JavaScript bundle.

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.

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

Audit output after the fixes: every check PASS, all checks passed
The same audit after the fix and a rebuilt bundle.
Terminal: the anonymous request now returns 0 rows; the test user sees only their own 14 contacts; inserting a deal for another account fails with a row-level security error
A stranger gets nothing, a user gets only their own rows, and writing into someone else’s account is refused.

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?

SituationWhat we’d suggest
Still finding product fit; most changes are screens and flowsStay on the builder, with the checklist above done
Integrations, background jobs, complex permissions or AI featuresBring developers in alongside the builder, in the same repository
Hosting costs, data-location rules or reliability needs outgrow the platformMove to your own hosting and stack

We’ve written separate notes on Lovable, Bolt, Replit and Rocket.

Mistakes we’d avoid

Sources

Spotted something out of date? Tell us

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.

Want a second pair of eyes before launch?

Share the link or the repository. On a free 30-minute call an engineer will tell you what stands between your app and real users.