FixVibe

// 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 SECURITY never 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

bash
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 strings

Any hit needs deletion plus key rotation. Provider dashboards: Supabase → Settings → API, Stripe → Developers → API keys, Anthropic / OpenAI console.

Database access controls

bash
# 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;` matches

Every 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

bash
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

bash
# 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.

ts
// 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.

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

ts
// 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.

ts
// 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).

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

bash
# 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:

  1. Service-role key in a client component

    Symptom: baas.supabase-service-key finding on the production URL. Cause: a Cursor autocomplete pasted createClient(URL, SERVICE_ROLE_KEY) into a React component. Fix: move the service client to src/lib/supabase/service.ts with import 'server-only' at the top; create a parallel src/lib/supabase/client.ts using the anon key for client-side use; rotate the service-role key via Supabase Studio.

  2. Firestore rules left in test mode

    Symptom: baas.firebase-rules high-severity finding. Cause: generated rules read allow 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-deploy firebase deploy --only firestore:rules.

  3. Permissive CORS surviving into production

    Symptom: active.cors high-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, set Access-Control-Allow-Origin explicitly in the response.

  4. RLS enabled but not forced

    Symptom: active baas.supabase-rls reports the anon role can write to a public table even though RLS is enabled in the dashboard. Cause: ENABLE without FORCE leaves the table owner exempt — and migrations run as owner. Fix: append alter table public.items force row level security; to the migration. Re-deploy.

  5. Unsigned IDOR-walkable IDs

    Symptom: active.idor-walking reports 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.

  1. Generate fast — use Cursor, Claude Code, Lovable, Bolt. That's the point.
  2. Audit immediately — run the grep set above, check RLS, verify CSP, review auth boundary.
  3. Harden at deploy — middleware, environment separation, CSP nonce, HSTS, server-only auth verification.
  4. Monitor — FixVibe passive daily, active weekly on a verified domain, webhooks to Slack, threat detection on Unlimited.
  5. 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.

// scan your app

Stop reading. Start finding the gaps in yours.

Drop in a URL — FixVibe runs every passive check from this guide plus 200+ others in under a minute. Free, no install, no card.

  • Free tier — 3 scans / month, no card.
  • Passive scans against any URL — no domain verification needed.
  • Tuned for Cursor, Claude Code, Lovable, Bolt, v0, Replit.
  • Coding-agent prompts for code/config findings, plus operator steps for DNS/provider fixes.
Run a free scan

no signup required