Why Your Vibe-Coded MVP Starts Breaking at 60 Days: Security Patterns, Architecture Fixes, and a Production Readiness Checklist
The Lovable BOLA incident exposed 8 million users' data through a 48-day unpatched vulnerability. Here are the five security failure patterns that show up in nearly every AI-generated codebase, with TypeScript fixes and a 15-point production readiness checklist.
In April 2026, Lovable, a $6.6 billion AI code generation platform with 8 million users, disclosed that a broken object-level authorization (BOLA) vulnerability had been sitting in their platform for 48 days. A free account holder could access any other user’s source code, database credentials, and AI chat histories with five API calls. Enterprise employees from Nvidia, Microsoft, Uber, and Spotify were exposed. Student records from UC Berkeley and UC Davis, including records belonging to minors, were accessible.
This was not an exotic attack. BOLA is number one on the OWASP API Security Top 10. The code that permitted it was almost certainly AI-generated, because Lovable is a platform where AI writes the code.
A separate study found that 91.5% of vibe-coded applications contained at least one AI hallucination-related vulnerability. Over 60% expose API keys or database credentials publicly. These are not edge cases.
If you built your MVP with Lovable, Bolt, Replit, Cursor, or any AI code generator, and you launched roughly two months ago, you are likely entering the window where these issues become visible. The first 60 days are forgiving. Users are few, load is low, and nobody is looking closely. Then traction happens, a developer joins your team and reads the codebase, or a security researcher runs a scan. What they find follows a predictable pattern.
This article covers the five failure modes that appear in nearly every AI-generated web application, explains why AI tools produce them systematically, and provides TypeScript code for each fix. It ends with a 15-point checklist you can run through today.
Why Day 60?
AI code generators optimize for demonstrating the happy path. Given a prompt like “build a user dashboard where users can see their projects,” the model generates code that works when the logged-in user owns the projects. It does not generate code that handles the case where an attacker modifies a project ID in the URL to access someone else’s data. The happy path passes, the demo looks great, and the vulnerability ships.
Technical debt from AI-generated code is not random. It concentrates in predictable places, and those places become load-bearing as the application grows. At launch you have one user: yourself. By day 60 you have real users, real data, possibly real payment information, and the structural gaps are now exploitable.
Failure Pattern 1: Inverted Authorization Logic
The Lovable BOLA was an instance of a broader pattern: authorization checks that verify authentication (is the user logged in?) but not authorization (is this user allowed to access this specific resource?).
A typical AI-generated resource endpoint looks like this:
// What AI generates: checks auth, skips ownership
app.get('/api/projects/:projectId', authenticate, async (req, res) => {
const project = await db.query(
'SELECT * FROM projects WHERE id = $1',
[req.params.projectId]
)
if (!project) return res.status(404).json({ error: 'Not found' })
return res.json(project)
})
Any authenticated user can access any project by changing the projectId parameter. The check confirms the session is valid, then fetches whatever ID was provided.
The fix is to include the user’s ID in every query that returns user-scoped data:
// Correct: ownership check is part of the query
app.get('/api/projects/:projectId', authenticate, async (req, res) => {
const project = await db.query(
'SELECT * FROM projects WHERE id = $1 AND owner_id = $2',
[req.params.projectId, req.user.id]
)
if (!project) return res.status(404).json({ error: 'Not found' })
return res.json(project)
})
The second parameter binds the result to the authenticated user’s ID at the database layer. An attacker who modifies the project ID receives a 404, not another user’s data. The authorization is enforced where it matters: at the query, not in application logic that can be bypassed.
For applications with a lot of resource types, a helper enforces this pattern consistently:
async function getOwnedResource<T>(
table: string,
resourceId: string,
ownerId: string
): Promise<T | null> {
const result = await db.query<T>(
`SELECT * FROM ${table} WHERE id = $1 AND owner_id = $2`,
[resourceId, ownerId]
)
return result.rows[0] ?? null
}
// Usage in handlers
app.get('/api/projects/:projectId', authenticate, async (req, res) => {
const project = await getOwnedResource<Project>(
'projects',
req.params.projectId,
req.user.id
)
if (!project) return res.status(404).json({ error: 'Not found' })
return res.json(project)
})
Audit every endpoint that accepts a resource ID as a URL parameter or request body field. That list is your BOLA attack surface.
Failure Pattern 2: Exposed API Keys and Credentials
AI code generators produce working examples by hardcoding credentials. The model has no concept of your deployment environment, so it puts the database URL, the Stripe secret key, and the OpenAI API key directly in the code. Developers ship it because the app works and the deadline is real.
GitHub’s secret scanning caught over 39 million hardcoded secrets in public repositories in 2023. The number has grown since. Vibe-coded repositories routinely contain credentials because the AI wrote them there and the non-technical founder did not know to look.
The immediate fix is to move every credential to environment variables and validate them at startup:
import { z } from 'zod'
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
OPENAI_API_KEY: z.string().startsWith('sk-'),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'staging', 'production']),
})
function loadEnv() {
const result = EnvSchema.safeParse(process.env)
if (!result.success) {
console.error('Invalid environment configuration:')
console.error(result.error.flatten().fieldErrors)
process.exit(1)
}
return result.data
}
export const env = loadEnv()
This pattern does two things: it removes credentials from source code, and it makes misconfigured deployments fail immediately at startup rather than halfway through a user request.
For credentials that were already committed, the git history retains them even after you remove the files. You must rotate every credential that appeared in a commit. Change the Stripe key, roll the database password, generate a new JWT secret, and regenerate the OpenAI API key. A removed secret that still appears in git history is still a live credential for anyone who clones the repository.
Add a .env.example file with placeholder values and a pre-commit hook using a tool like git-secrets or truffleHog to prevent future commits containing credential patterns.
Failure Pattern 3: Missing Input Validation
AI-generated API handlers accept and trust input from the client. The model was trained on code that demonstrates functionality, and functionality is easier to demonstrate when you assume the input is well-formed. The result is handlers that pass user input directly to database queries, template rendering, or downstream APIs without validation.
The two consequences are SQL injection (mitigated but not eliminated by parameterized queries) and business logic violations (charging a negative amount, setting a role to “admin”, uploading a 2GB file). Both show up consistently in vibe-coded codebases.
A Zod-based request validator catches both categories:
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'
function validateBody<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({
error: 'Invalid request',
details: result.error.flatten().fieldErrors,
})
}
req.body = result.data
next()
}
}
// Example: creating a project
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).trim(),
description: z.string().max(500).trim().optional(),
visibility: z.enum(['private', 'public']),
})
app.post(
'/api/projects',
authenticate,
validateBody(CreateProjectSchema),
async (req, res) => {
// req.body is now typed and validated
const { name, description, visibility } = req.body
const project = await createProject({
name,
description,
visibility,
ownerId: req.user.id,
})
return res.status(201).json(project)
}
)
Note that the visibility field uses z.enum. If your application logic distinguishes between user roles, plan tiers, or resource states, those values must be enumerated in your schema. A field that accepts any string value is a field an attacker can set to "admin" or "enterprise".
For file uploads, validate content type at the server (not just the extension the client provides) and enforce size limits before writing to storage:
const ALLOWED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
])
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
function validateUpload(req: Request): { valid: boolean; error?: string } {
const contentType = req.headers['content-type'] ?? ''
const contentLength = parseInt(req.headers['content-length'] ?? '0', 10)
if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
return { valid: false, error: `Unsupported file type: ${contentType}` }
}
if (contentLength > MAX_FILE_SIZE) {
return { valid: false, error: 'File exceeds 5MB limit' }
}
return { valid: true }
}
Failure Pattern 4: Hardcoded Credentials and Default Configurations
Beyond API keys, AI-generated code often contains hardcoded values that function as implicit credentials: a default admin password, a fixed session secret, a JWT signing key set to "secret", or database connection credentials embedded in a Docker Compose file committed to the repository.
These appear because the model is optimizing for a working local development environment. In that context, a JWT secret of "dev-secret" works fine. In production, it means any attacker who knows your library can forge authentication tokens.
The fix for JWT configuration looks like this:
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
// Validate at startup that the secret is production-grade
const JWT_SECRET = env.JWT_SECRET
if (JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters')
}
interface TokenPayload {
userId: string
email: string
role: 'user' | 'admin'
}
export function signToken(payload: TokenPayload): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: '15m',
algorithm: 'HS256',
issuer: 'your-app-name',
})
}
export function verifyToken(token: string): TokenPayload {
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'your-app-name',
})
return decoded as TokenPayload
}
// Generate a properly random secret for first-time setup
export function generateSecret(): string {
return crypto.randomBytes(48).toString('hex')
}
The algorithms array in verifyToken prevents algorithm confusion attacks, where an attacker switches the algorithm to none and submits an unsigned token. Specifying the algorithm explicitly at verification rejects tokens using any other algorithm.
For session secrets and similar values that need to be random but stable across deployments, generate them once with crypto.randomBytes(48).toString('hex') and store the output in your secrets manager, not in source code.
Failure Pattern 5: No Test Coverage
AI code generators produce code that passes manual testing. The developer clicks through the UI, the happy path works, and the feature ships. What does not exist is a test suite that would catch a regression when you modify the authorization logic in failure pattern 1.
This matters because the fix for each pattern above changes application behavior. When you add ownership checks to database queries, you need a test that confirms a user cannot access another user’s resource. Without that test, a future refactor or a new developer can reintroduce the vulnerability without knowing it.
Here is a minimal auth and ownership test using Vitest that catches BOLA regressions:
import { describe, it, expect, beforeAll } from 'vitest'
import { createApp } from '../src/app'
import { createTestUser, createTestProject, signTestToken } from './helpers'
describe('Project ownership enforcement', () => {
let app: ReturnType<typeof createApp>
let userA: { id: string; token: string }
let userB: { id: string; token: string }
let projectOwnedByA: { id: string }
beforeAll(async () => {
app = createApp()
userA = await createTestUser()
userB = await createTestUser()
projectOwnedByA = await createTestProject({ ownerId: userA.id })
})
it('returns project to owner', async () => {
const res = await app.request(
`/api/projects/${projectOwnedByA.id}`,
{ headers: { Authorization: `Bearer ${userA.token}` } }
)
expect(res.status).toBe(200)
})
it('returns 404 when non-owner requests a project', async () => {
const res = await app.request(
`/api/projects/${projectOwnedByA.id}`,
{ headers: { Authorization: `Bearer ${userB.token}` } }
)
// Must be 404, not 200 or 403 leaking resource existence to attacker
expect(res.status).toBe(404)
})
it('rejects unauthenticated requests', async () => {
const res = await app.request(`/api/projects/${projectOwnedByA.id}`)
expect(res.status).toBe(401)
})
})
The second test is the critical one. It confirms that user B receives a 404 (not data belonging to user A) when requesting a project they do not own. This test would have caught the Lovable BOLA variant before it shipped.
| Pattern | What AI generates | Production risk | Fix |
|---|---|---|---|
| Object-level authorization | Auth check only, no ownership | Any authenticated user accesses any resource | Bind resource queries to owner_id |
| Exposed credentials | Hardcoded API keys and secrets | Repository exposure, credential theft | Environment variables, startup validation |
| Missing input validation | Trust client input directly | Injection, business logic bypass | Zod schema on every request body |
| Default secrets | "secret" as JWT key, admin/admin | Token forgery, account takeover | crypto.randomBytes, secrets manager |
| No tests | Manual happy-path only | Regressions ship undetected | Ownership and auth tests before refactoring |
The 15-Point Production Readiness Checklist
Run through this before you consider your MVP production-ready. Each item maps to a failure mode that has been observed in AI-generated codebases.
Authorization
- Every API endpoint that accepts a resource ID (in the URL, query string, or body) includes the authenticated user’s ID in the database query.
- Admin routes are protected by a role check, not just an authentication check. A user who sets their own
rolefield to"admin"cannot access admin endpoints. - Bulk list endpoints filter by
owner_id. An endpoint that returns all items in a table, then filters in application code, is still fetching all rows.
Secrets and Configuration
- No credentials appear in any committed file, including Docker Compose,
.envfiles checked into the repository, and configuration files. - Every secret rotates independently. Database password, JWT secret, and third-party API keys are separate values, not a shared master secret.
- The application fails at startup with a clear error message if any required environment variable is missing or malformed.
- JWT signing uses a 256-bit minimum secret, specifies the algorithm explicitly, and verifies the algorithm at token validation.
Input Validation
- Every request body is validated against a schema before being passed to any database call or business logic function.
- File uploads validate content type at the server, not at the client, and enforce a size limit before writing bytes to storage.
- Numeric inputs from users (amounts, quantities, IDs) are validated as non-negative integers where negative values would cause business logic errors.
Infrastructure
- Database connections use a single authenticated role for the application, and that role does not have DROP TABLE or CREATE TABLE permissions.
- HTTPS is enforced at the infrastructure layer, not just recommended. HTTP requests redirect to HTTPS. The
Strict-Transport-Securityheader is set. - Rate limiting is applied to authentication endpoints (login, password reset, token refresh). An endpoint that allows unlimited login attempts is a brute force target.
Observability and Testing
- At least one automated test per resource type confirms that user A cannot access a resource owned by user B.
- Error responses do not include stack traces, SQL queries, or internal service URLs in production. The client receives a generic error message; the details go to a log.
What to Do This Week
If you are running a vibe-coded MVP in production right now, the highest-leverage actions in order are:
Start with the authorization audit. List every endpoint that accepts a resource ID. Verify that each query includes owner_id = current_user_id. This takes an afternoon and eliminates the class of vulnerability that affected 8 million Lovable users.
Rotate every credential that was ever committed to git. Even if you removed it from the codebase, the git history is a permanent record. Change the value, invalidate the old one.
Add environment variable validation at startup. The Zod pattern above takes 30 minutes to implement and prevents credential misconfigurations from reaching users.
Write three tests per resource type: owner can access, non-owner gets 404, unauthenticated gets 401. These tests protect every future refactor.
The 60-day wall is not a failure of AI tools. The tools did what they were designed to do: generate code that demonstrates functionality quickly. The gap is in what comes next, the ownership checks, the validated configuration, the test coverage that keeps a working application working as it grows.
The patterns are predictable. The fixes are mechanical. The window to address them before they become incidents is exactly now.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.