// docs / security guides / hardening
How to secure an app built with AI coding tools
A step-by-step hardening guide for apps you built with Cursor, Claude Code, Lovable, Bolt, v0, Replit, or Windsurf. Four phases: understand why AI-generated apps fail differently, run an immediate codebase audit, harden at deploy time, then keep monitoring. Opinionated, narrative, with real snippets you can copy.
Why AI-generated apps fail differently
Vibe-coded apps can be secure. They need an extra audit pass because the failure modes are structural, not careless:
- Cursor inlines hardcoded keys. You ask Cursor to "fix the auth error," and it pastes a Supabase example that assumes a service-role client. The key ends up at the top of a page component. Both the anon client and the service client coexist; both ship.
- Claude Code defaults to permissive CORS. Generated Express / Fastify handlers ship with
cors({ origin: '*' })because that's the fastest way to get a working preview. The middleware never gets a second pass. - Lovable and v0 skip the rules file. Firestore-backed projects generate the data model but rarely touch
firestore.rules. Test-mode rules expire silently and lock the database with no warning to the user. - Bolt skips RLS migrations. Bolt generates a Supabase schema and a CRUD surface that uses the anon key.
ENABLE ROW LEVEL SECURITYnever enters the migration. Anonymous users can read or write any row. - Windsurf trusts unsigned IDs. Generated
GET /api/items/[id]reads the param and queries Postgres without verifying ownership. The pattern is common enough that the active active.idor-walking probe surfaces it within a single scan.
The immediate audit: grep your codebase for risk patterns
Before you harden anything, find what's already broken. These greps each take under a minute:
Secrets and provider keys
grep -RIn 'NEXT_PUBLIC_SUPABASE_SERVICE' src/
grep -RIn 'sk_live_\|pk_live_\|STRIPE_SECRET' src/
grep -RIn 'sk-ant-\|^sk-' src/ # Anthropic / OpenAI
grep -RIn 'AIza\|AKIA' src/ # Google / AWS
grep -RIn 'eyJh[A-Za-z0-9_-]\{20,\}' src/ # JWT-shaped stringsAny hit needs deletion plus key rotation. Provider dashboards: Supabase โ Settings โ API, Stripe โ Developers โ API keys, Anthropic / OpenAI console.
Database access controls
# Supabase migrations
grep -RIn 'CREATE TABLE public\.' supabase/migrations/
grep -RIn 'ENABLE ROW LEVEL SECURITY\|FORCE ROW LEVEL SECURITY' supabase/migrations/
# Firebase / Firestore
cat firestore.rules # confirm no `if true;` matchesEvery CREATE TABLE public.* needs a matching ENABLE ROW LEVEL SECURITY and at least one policy. Firestore rules must scope reads to request.auth.uid.
Auth and session handling
grep -RIn 'getSession()' src/ # should be getUser() server-side
grep -RIn 'localStorage\.\(set\|get\)Item.*token' src/
grep -RIn 'jwt.verify.*\(noVerify\|skipVerify\)' src/Server-rendered routes must use supabase.auth.getUser() โ it verifies with the backend. getSession() reads an unverified cookie. Tokens in localStorage are accessible to any script that runs on the page.
Headers and middleware
# Confirm middleware location for src/ layouts
ls src/middleware.ts middleware.ts 2>&1
# Look for CSP and security headers
grep -RIn 'Content-Security-Policy\|Strict-Transport-Security' src/With the src/ layout, only src/middleware.ts is picked up. If your middleware file is at the project root, Next.js silently ignores it and your CSP / auth-refresh logic never runs.
Hardening at deploy time
Once source is clean, lock down how the app reaches production.
Step 1: Separate environments
Vercel: three environments โ Production (your prod domain), Preview (PR / staging deploys), Development (local). Each gets its own env-var set. Live Stripe / Anthropic / Supabase keys never reach Preview; Preview keys never reach Production. Branches push to Preview automatically; merge to main deploys to Production.
Step 2: Strict CSP via middleware
Generate a per-request nonce, then inject it into Content-Security-Policy. Next.js auto-applies the nonce to its own script tags when you set the x-nonce request header.
// src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = crypto.randomUUID().replace(/-/g, '');
const csp = [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://*.supabase.co`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Step 3: Force RLS on every public table
RLS isn't enabled by default and isn't enforced for table owners unless you FORCE it. Pair each table with explicit policies per role.
-- supabase/migrations/XXXX_rls.sql
alter table public.profiles enable row level security;
alter table public.profiles force row level security;
create policy "profiles: read own"
on public.profiles for select
using (auth.uid() = id);
create policy "profiles: update own"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);Step 4: Server-only auth verification on every API route
Every state-changing API route verifies the caller server-side with supabase.auth.getUser(). The user object becomes the source of truth for user_id โ never trust a request body to set it.
// src/app/api/items/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const body = await request.json();
const { data, error } = await supabase
.from('items')
.insert({ ...body, user_id: user.id }) // server-supplied, not from body
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
return NextResponse.json(data);
}Step 5: Reverse-proxy your analytics
Proxying analytics through your own domain avoids ad-blockers and lets your CSP connect-src 'self' stay narrow. Same pattern works for PostHog, Plausible, Umami, custom event sinks.
// src/app/api/posthog/[...path]/route.ts
import { type NextRequest } from 'next/server';
const UPSTREAM = 'https://us.i.posthog.com';
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const url = `${UPSTREAM}/${path.join('/')}`;
return fetch(url, {
method: 'POST',
headers: { 'content-type': req.headers.get('content-type') ?? 'application/json' },
body: await req.text(),
});
}Step 6: Open-redirect guard on the post-auth bounce
Sign-in / sign-up flows commonly accept a next query param. Reject anything that isn't a same-site path โ start with / and never // (protocol-relative, sends users off-site).
function safeNext(raw: string | null): string {
if (!raw) return '/dashboard';
if (!raw.startsWith('/') || raw.startsWith('//')) return '/dashboard';
return raw;
}Ongoing: monitoring and re-scanning
Drift happens on every deploy. Treat security as a loop, not a checklist you finish.
Verify your production domain
Dashboard โ Domains โ add your prod domain โ DNS TXT or HTTP-file verification (single-step). Once verified, active scans become available and scheduled re-scans can be enabled.
Schedule passive re-scans
Daily on Hobby, 3-hourly on Pro, hourly on Unlimited. Each run emails you if a new finding appears, and fires a scan.completed webhook if you've subscribed.
# Or from CI, via the REST API:
curl -X POST https://fixvibe.app/api/v1/scans \
-H "authorization: Bearer $FIXVIBE_TOKEN" \
-H "content-type: application/json" \
-d '{"target":"https://your-app.com"}'Enable API-active scans (optional)
If you want automated active probing (SQLi / XSS / IDOR walking / etc.), turn it on per domain at Dashboard โ Domains โ API active. Authorization is durable, 90-day expiry, instantly revocable. Pair with the scan.active_api.first_used webhook so the first automated active scan after enablement reaches your alerting.
Wire findings into your AI workflow
Mint an API token at Account โ API tokens, then configure the MCP server (/docs/mcp) in Claude Desktop / Cursor / Continue. Ask your agent: "Run a scan on staging and show me the highest-severity findings." The agent calls FixVibe, fetches the report, and renders categorized remediation guidance so code/config fixes become prompts and DNS/provider/manual fixes become operator steps.
Live threat detection (Unlimited)
Certificate-transparency log diffs surface new TLS certs issued for your domain. DNS-record diffs catch unauthorized changes. JS-bundle secret monitoring fires the moment a new key reaches a shipped bundle. Threat-intel feeds (Spamhaus, URLhaus) report your domain if it's listed.
Real failure patterns and their fixes
Five patterns from production scans across thousands of AI-generated apps, each with the actual fix:
- Service-role key in a client component
Symptom:
baas.supabase-service-keyfinding on the production URL. Cause: a Cursor autocomplete pastedcreateClient(URL, SERVICE_ROLE_KEY)into a React component. Fix: move the service client tosrc/lib/supabase/service.tswithimport 'server-only'at the top; create a parallelsrc/lib/supabase/client.tsusing the anon key for client-side use; rotate the service-role key via Supabase Studio. - Firestore rules left in test mode
Symptom:
baas.firebase-ruleshigh-severity finding. Cause: generated rules readallow read, write: if request.time < timestamp.date(2026, 6, 1);โ a time-bounded "allow all". Fix: scope each rule to the authenticated user โmatch /users/{userId}/posts/{postId} { allow read, write: if request.auth.uid == userId; }โ and re-deployfirebase deploy --only firestore:rules. - Permissive CORS surviving into production
Symptom:
active.corshigh-severity. Cause: generated Express middleware:app.use(cors({ origin: '*' })). Fix: allowlist your frontend origin:app.use(cors({ origin: ['https://your-app.com'], credentials: true })). For Next.js API routes, setAccess-Control-Allow-Originexplicitly in the response. - RLS enabled but not forced
Symptom: active
baas.supabase-rlsreports the anon role can write to a public table even though RLS is enabled in the dashboard. Cause:ENABLEwithoutFORCEleaves the table owner exempt โ and migrations run as owner. Fix: appendalter table public.items force row level security;to the migration. Re-deploy. - Unsigned IDOR-walkable IDs
Symptom:
active.idor-walkingreports the anon user can read/api/items/1,/api/items/2, ... across tenants. Cause: the API handler trusts the path param and queries Postgres without an ownership predicate. Fix: add.eq('user_id', user.id)on every read query, or move to signed URLs / UUIDs scoped under/api/users/[uid]/items/[id].
The vibe-code security loop
The goal isn't perfect security; it's eliminating the low-hanging fruit AI tools consistently miss so you can keep shipping fast.
- Generate fast โ use Cursor, Claude Code, Lovable, Bolt. That's the point.
- Audit immediately โ run the grep set above, check RLS, verify CSP, review auth boundary.
- Harden at deploy โ middleware, environment separation, CSP nonce, HSTS, server-only auth verification.
- Monitor โ FixVibe passive daily, active weekly on a verified domain, webhooks to Slack, threat detection on Unlimited.
- Fix fast โ use FixVibe coding-agent prompts for code/config findings and operator steps for DNS, provider, secret-rotation, or manual-review findings. Re-deploy, re-scan, close the loop.
Next steps
For the conceptual backdrop on DAST vs SAST and why AI-generated apps need their own scanning, read AI-generated code security scanning. For a quick-reference pre-ship audit, see the vibe coding security checklist.
