A founder we know shipped his MVP in four weeks with Lovable. By week eight, his Stripe secret key, sitting in a NEXT_PUBLIC_* variable inside the client bundle, had been pulled out of his JS chunks and used to issue refunds to every customer in the database overnight. He woke up to a five-figure hole and a Stripe account in review.
That exact incident was one project. The pattern is not unusual, and it is not a reason to stop using Lovable, Bolt, v0, or Replit. It is a reason to know what to fix first when you inherit one of these codebases. In appssemble's inherited-codebase work, we have run this sequence on more than a dozen vibe-coded projects, and the pattern is so consistent it has become a checklist.
The work is not a rewrite. It is a sequenced replacement of seven specific systems, in priority order by blast radius. Most teams do them out of order, or skip the first three.
Why vibe-coded apps feel fast, then aren't
The acceleration is real. A founder with a clear idea and Lovable can have a functional product in front of users in two weekends. The issue is what gets skipped to make that happen.
Public research points in the same direction, but the numbers measure different slices of the problem. Veracode's 2025 GenAI benchmark found that 45% of generated code samples failed security tests with OWASP Top 10 issues. CodeRabbit's 470-PR study found AI-authored pull requests had 1.7x more issues overall, with security issues up to 2.74x higher.
In Lovable specifically, Matt Palmer's scan reported 303 vulnerable endpoints across 170 projects, about 10.3% of 1,645 analyzed. NVD marks CVE-2025-48757 as supplier-disputed, but the failure mode is concrete enough to test directly. SupaExplorer's self-published scan reported an 11.04% critical exposure rate across 20,052 launch URLs. The distinction matters: Supabase anon and publishable keys are meant to be public; service_role and secret keys are not, and public keys become dangerous when grants and RLS are wrong.
None of this means the tools are bad. It means the output is a draft. Treat it like a PR from a junior dev: review it, then refactor it. The question is what to refactor first.
The blast-radius pyramid
We sequence the work by what hurts most if left alone for one more week, not by what is most fun to fix. In appssemble's inherited Lovable, Bolt, and Supabase projects, items 1 to 3 account for most of the active security risk we find. Items 4 to 7 are what stops the codebase from regressing back into chaos within a sprint.
Imagine you're a CTO who just took over a paying B2B SaaS built in eight weekends with Lovable and Supabase. You have 200 customers, $30K MRR, and a roadmap your investors expect to ship in Q3. You cannot pause for three months to rewrite. In our engagements, the first sprint is for items 1 to 3; the rest usually lands over the next two to four weeks, and the order is the value.
1. Secrets and environment management: Pull every secret or elevated key out of the client bundle, rotate the leaked ones, move to a real vault. A leaked Stripe key is a Tuesday-afternoon disaster; everything else on this list assumes you still have a business to fix.
2. Row Level Security and authorization: Enable RLS on every table exposed through the Data API, review grants for anon and authenticated, replace role-only policies with ownership or tenant checks, and remove any service_role or secret key from client code. Vibe App Scanner cites an 83% RLS-misconfiguration share in exposed Supabase data exposures; treat that as directional field data, not a platform-wide benchmark.
3. Auth flow and session handling: Replace localStorage-based "isAdmin" booleans with cookie-backed SSR sessions and server-verified authorization. localStorage is JavaScript-readable and trivially spoofable; one documented case had admin access gated entirely by a localStorage flag the user could set themselves in DevTools.
4. A typed data layer: Rip inline supabase.from(...) calls out of components, generate types from the database, and wrap every query in a Zod-validated function.
5. Error handling, logging, and observability: Wire Sentry, PostHog, and a structured server logger. Add error boundaries, stop swallowing promises, and make the litmus test "would we know about a customer's bug before they emailed?"
6. CI, hooks, and a deploy pipeline: GitHub Actions for typecheck → lint → test → build, branch protection on main, preview deploys per PR, secret scanning in CI.
7. Test scaffold and dead-code sweep: A Vitest + Playwright + MSW skeleton, Knip for dead code and dependencies, Madge for circular imports, and a Supabase declarative-schema baseline so future migrations start from something readable.
What we do not replace on day one matters too. Keep the UI if users understand it. Keep Supabase if RLS and grants can be made correct. Do not replace auth before secrets and authorization are contained. The goal is to reduce blast radius, not prove engineering taste.
The rest of this post is one section per item. Each has the specific tool we use, the specific code-level change, and the reason it sits where it does in the queue.
1. Pull every secret out of the browser
Every vibe-coded codebase we have inherited has at least one of: a hardcoded secret key in a .tsx file, a service_role Supabase key shipped to the browser, or a .env.local checked into git. The AI optimizes for "the call works," not "the key is server-side." The output that gets generated tends to look like this: a fetch to an LLM provider with the bearer token written inline, sitting inside a client component, visible to anyone with DevTools.
Our day-one sequence:
- Run
rg -n 'sk_|sk-|service_role|supabase.*eyJ|AKIA' --hiddenacross the repo. Surface every literal that smells like a secret, then classify it instead of blindly deleting it. - Rotate every secret found, including the ones you think were never pushed. Assume git history leaked them.
- Audit every
NEXT_PUBLIC_*variable. Anything that does not actually need to be in the browser becomes a server-only var. This is the single most common own-goal in the category. - Stand up Doppler or Infisical, sync to Vercel for Preview and Production, mirror the schema with
doppler runorinfisical runfor local dev. We default to Doppler for small teams and Infisical when self-hosting matters. - Move server-side keys (Stripe secret, OpenAI, Supabase
service_role) into server actions, route handlers, or edge functions. They never get imported from a'use client'file. - Add
gitleaksortrufflehogto CI so a future regression fails the build.
The founder handoff sentence is simple: can you confirm no Stripe, OpenAI, Supabase service_role, or other secret key is present in browser-delivered code? A normal Supabase anon or publishable key is not the problem by itself. A secret key in the browser is.
This is item one because it is the only item where the cost of waiting is open-ended. Every other system on this list can be triaged in priority order. A leaked Stripe key cannot.
2. Fix Row Level Security before you change anything else
This is the single biggest pattern in the entire vibe-coded category, and it is why CVE-2025-48757 got attention. Matt Palmer's scan reported 303 vulnerable endpoints across 170+ Lovable apps, emails, addresses, API keys, financial records. NVD marks the record as supplier-disputed, but the test is still straightforward: in an exposed schema with broad grants and missing or wrong RLS, the public anon or publishable key can read rows through the Supabase REST endpoint. No exploit kit required.
The four failure modes we see, ranked by frequency:
- RLS disabled entirely. Tables created via Lovable's UI sometimes ship without RLS turned on. On an exposed schema with permissive grants, the public key can access more than anyone intended.
- **
auth.role() = 'authenticated'policies.** This only checks "is anyone logged in," not "does this row belong to this user." On private user data, that is the wrong test because every authenticated user can read every other user's rows. - **
USING (true)policies.** These get generated when the AI hits a "permission denied" error and the founder asks it to make the error go away. - Inverted logic. One real example we have seen in the rescue literature: authenticated users blocked, anonymous users granted full read.
The replacement pattern is the SQL you would expect, enable RLS on each table in an exposed schema, review grants for anon and authenticated, then write auth.uid() = user_id or tenant-scoped policies for select, insert, update, and delete, with with check clauses on writes. The Supabase Performance/Security Advisor and supabase db lint will surface every table with RLS disabled. For tables that have policies, test three contexts: unauthenticated publishable or anon access, user A's JWT, and user B's JWT. If user A can read or write user B's row through the REST endpoint, the policy is broken, regardless of what the UI hides.
Vibe App Scanner and SupaExplorer offer automated scans that generate copy-paste fix SQL, useful as a second opinion, never as the primary sign-off. The founder handoff sentence: can you test direct REST access as anonymous, user A, and user B, then prove cross-user reads fail?
The hard rule, no exceptions: no service_role key in client code, ever. If we find one, we rotate immediately and redesign the call to go through a server action or edge function. There is no version of this where you keep the convenient call and add a comment.
3. Replace localStorage auth with server-verified sessions
Vibe-coded auth is almost always built around localStorage, which is XSS-readable. The ugliest case we keep seeing in the wild, documented by Sola Security in their vibe-coding vulnerability catalog, is admin dashboards gated entirely by checking whether a localStorage property is set to true. A user opens DevTools, types one line, and is in.
Even when Supabase Auth is properly wired, the generated code often calls supabase.auth.getSession() server-side. Supabase's own docs are explicit: always use supabase.auth.getUser() to protect pages and user data, and never trust getSession() inside server code such as middleware. The difference is whether the token is verified against the Auth server or just read from a cookie that the user might control.
The replacement:
- Migrate from the deprecated
@supabase/auth-helpersto@supabase/ssr. Sessions move out of localStorage into Supabase's cookie-backed SSR flow. UseSecureandSameSite=Laxor stricter settings, but do not call them HttpOnly unless you have built a separate backend session model; the normal browser Supabase client still needs access to its own auth cookies. - One
createServerClientfactory inlib/supabase/server.ts, onecreateBrowserClientinlib/supabase/client.ts, and middleware that refreshes tokens on every navigation. - Server authorization uses
getUser()when it needs the current user record, or verified claims when that is enough. It never trusts a localStorage flag or a rawgetSession()result for access control. - Any "isAdmin" or role check moves into a server-controlled source of truth: locked profile or role tables, JWT
app_metadata, or a security-definer function. Users must never be able to update their own authorization role.
If the app has multi-tenancy or real roles, we will bring in Auth.js and put Supabase behind it, but only after items 1 and 2. Replacing auth before fixing RLS is rearranging deck chairs.
4. Build a typed data layer between components and the database
Lovable, Bolt, and v0 all generate the same anti-pattern: supabase.from('users').select('*') written directly inside a React component, typed as any, with no validation, no error normalization, and no runtime guarantee the shape is what TypeScript thinks it is. This breaks four things at once: testability (you cannot mock), reusability (the same query gets rewritten in six components), performance (SELECT * everywhere), and type safety (the schema can drift from the code with no compile error).
Our replacement is one pattern, applied table by table:
npx supabase gen types typescript --linked > src/types/database.ts, pin the schema to the codebase, regenerate on every deploy.- Generate Zod schemas from those types with supazod (preferred) or
supabase-to-zod. Now every row has a compile-time type and a runtime validator. - Every table gets a function in
src/data/<table>.tsthat does the query, throws a typedDataLayerErroron failure, and runs the result throughz.array(Schema).parse(data)before returning. - Components call those functions through TanStack Query, never
supabase.fromdirectly. An ESLintno-restricted-importsrule bans@supabase/supabase-jsfromapp/**/*.tsx. - Configure the QueryClient with sane defaults, TanStack's docs are clear that the default
staleTime: 0makes every render refetch. We default tostaleTime: 60_000,gcTime: 5 * 60_000,retry: 1,refetchOnWindowFocus: false.
This single pattern is what turns the codebase from "I'm scared to change the schema" into "I can rename a column on Tuesday."
5. Wire observability before you ship the next feature
AI-generated code skips error handling and edge cases. We see two patterns: silent .catch(() => {}) blocks that swallow every failure, and unhandled promise rejections that crash a server route on a transient DB error. There are usually zero error boundaries in the React tree, no central logger, no Sentry, and no product events being captured. Production bugs surface only when a customer emails.
What we install, in roughly this order:
- Sentry with
instrumentation.tsplus the client, server, and edge configs. In App Router apps, missing the supported server and edge initialization path creates monitoring blind spots right where production bugs hide. - **A
beforeSendfilter from day one.** We filter expected auth and validation noise:UNAUTHENTICATED,FORBIDDEN, andBAD_USER_INPUT, so real exceptions do not drown in 401s and 403s. - PostHog for product analytics and session replay (or Plausible if you do not need replay). Autocapture off by default; explicit
posthog.capture('invoice_created')calls in mutation handlers. - Pino for structured server logs, piped to Axiom or Better Stack.
- One error boundary at the route level, plus a
<Toast />for caught-and-shown errors. Every async mutation routes through ahandleError(err, context)function that logs, shows a toast, and tags Sentry. - OpenTelemetry traces via
@vercel/otelif the backend has any complexity worth tracing.
The litmus test we use on every project: could a customer hit a bug right now and would we know about it before they emailed? If no, we are not done with item 5.
6. Build a CI pipeline so the next regression is caught by a robot
Vibe-coded projects ship straight from the AI's preview environment to production. There is no main branch protection, no PR template, no CI, no preview deploys, and often no GitHub repo at all: the code lives inside the Lovable workspace until someone pulls it out. The first time a new engineer runs npm run build locally, it fails on a missing dependency.
The pipeline we install:
- GitHub Actions with one workflow:
typecheck → lint → test → build, parallelized where possible, runs on every PR. Usepnpm/action-setupplusactions/setup-nodewithcache: 'pnpm', orpnpm/action-setupstore caching. Cache the pnpm store, notnode_modules. - **Branch protection on
main:** required status checks, required PR review, no force pushes, linear history. - Preview deploys per PR via Vercel, ideally with a Supabase branch DB so PRs do not pollute staging data.
- Lefthook for pre-commit hooks, lighter and faster than Husky for monorepo-ish setups. Lints staged files only, runs Prettier, blocks committing
console.log. Husky plus lint-staged is fine for smaller projects. - **
gitleaksin CI** to fail the build on any committed secret pattern. - Migrations gated: run drift checks against staging, preview, or a locked CI database instead of production by default. Generated diffs get reviewed before they become migrations.
On small inherited apps, our first useful pipeline usually takes 90 minutes to half a day. It does not need Slack notifications or release theatre. It needs to stop broken code from reaching main.
7. Sweep the dead code, then write the first test
In the Lovable codebases we audit, zero tests is common, not "incomplete coverage," literally zero. We also find dependency piles, component files that import each other in cycles, and exported symbols that nothing imports. Refactoring is terrifying because there is no safety net and you cannot tell what is load-bearing.
The sweep:
- Knip (
npx knip), covers unused files, exports, dependencies, and class members in one pass. It has effectively replacedts-prunein our stack. - Madge (
npx madge --circular --extensions ts,tsx src), surfaces circular imports. We often find 5 to 15 cycles in vibe-coded codebases, each one capable of breaking Vite or Next builds in subtle ways. - **
depcheck** as a second opinion on dependency cleanup. - Supabase declarative schemas as the migration baseline:
supabase db dump > supabase/schemas/prod.sql, split into per-domain files, thensupabase db diffgenerates cleaner migrations going forward. This kills the "I have 47 random migrations and don't know which ones are applied" problem, but it is not autopilot. RLS policies, grants, view behavior, and destructive diffs still need explicit review.
In a typical appssemble cleanup pass on an inherited Lovable codebase, we remove 20 to 35 npm packages and 600 to 2,000 lines of dead code on day one.
Then the test scaffold: Vitest for unit and integration, MSW for HTTP mocking, Playwright for one happy-path E2E test per primary user flow. The first test we always write is "user can sign up and see their dashboard." It exercises auth, routing, the database, and the dashboard in one path, and it forces the test database to actually exist.
"Can't we just rewrite it?"
The objection we hear from CTOs at this point is always the same: this looks like a lot of work, why not rewrite from scratch?
Two reasons. The founder has paying customers, and a rewrite means freezing the product for two to four months while you rebuild what already works. And rewrites of unfamiliar codebases tend to ship the same bugs, because nobody on the new team knows which weird edge case in the old code was load-bearing for a real customer. AlterSquare's rescue write-up on 15+ AI-broken codebases claims 33% to 67% of AI-generated code requires manual fixes, with some projects requiring 10x the time initially saved. Treat that as rescue-shop data, not a universal benchmark. The direction matches what we see: a sequenced replacement preserves the working parts and replaces only the dangerous ones.
The other answer we hear from teams who have not done this before is "let's just add tests first." That is item seven for a reason. Tests around a codebase that is leaking secrets and missing RLS will pass green while customers' data walks out the door.
The order is the value
If you take one thing away from this post, take this: the seven items are not a menu. They are a sequence. We have watched teams spend three weeks on item six (a beautiful CI pipeline with conventional-commit linting and Slack notifications) while item two (Supabase tables with broken RLS) sat untouched. The pipeline was real engineering work. It was also the wrong work for week one.
The way we run this on our own engagements: we triage the codebase against this list on day one, write a one-page priority memo, and replace items 1 through 3 inside the first sprint. Items 4 through 7 land over the next two to four weeks, in order, behind feature flags where possible, with every change in a tagged PR so the founder inherits institutional knowledge along with the code. We use our engineering team the same way we would on a greenfield build, small, senior, owning the whole stack, except the work is replacement instead of construction.
If you are sitting on a vibe-coded codebase and wondering what a senior team would fix first, book a call. We have run this sequence across appssemble's inherited-codebase work, and the first conversation starts with a priority memo, not a rewrite estimate.