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:

  • Assistants inline hardcoded keys. You ask for a fix to an auth error and get a pasted 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.
  • Generated servers default to permissive CORS. Generated Express / Fastify handlers often ship with cors({ origin: '*' }) because that's the fastest way to get a working preview. The middleware never gets a second pass.
  • Rules files get skipped. Firestore-backed projects generate the data model but rarely touch firestore.rules. Test-mode rules let anyone read and overwrite data until someone replaces them.
  • RLS never enters the migration. A generated Supabase schema and CRUD surface use the anon key, but ENABLE ROW LEVEL SECURITY never enters the migration. Anonymous users can read or write any row.
  • Handlers trust IDs. A generated GET /api/items/[id] reads the param and queries Postgres without verifying ownership. Active scans on a verified domain test for this (IDOR / BOLA).

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 on tables you create in SQL or migrations. Enable it on every exposed table and pair each one with explicit policies per role โ€” that is what stops the anon and authenticated roles. FORCE only makes the table owner obey RLS too.

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 production domain โ†’ DNS TXT or HTTP-file verification. Active scans require Hobby or above; scheduled re-scans require Pro or Unlimited.

Schedule passive re-scans

Scheduled re-scans are available on Pro and Unlimited for verified domains. Free and Hobby scans are manual. Scheduled scans use your plan allowance. Configure completion email preferences and a scan.completed webhook if needed.

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

On Hobby or above, create an API token at Account โ†’ API tokens and configure the MCP server (/docs/mcp) in your coding tool. Ask your agent to run an authorized scan and inspect the highest-severity findings. Code fixes can use remediation prompts; provider and DNS fixes may need manual operator steps.

Live threat detection (Unlimited)

Periodic certificate-transparency, DNS, JS-bundle, and threat-intelligence checks report observed changes on supported signals. Alerts depend on successful polling and source availability; they do not establish continuous or complete security coverage.

Real failure patterns and their fixes

Five common patterns in AI-generated apps, each with the actual fix:

  1. Service-role key in a client component

    Symptom: FixVibe reports an exposed Supabase service-role key on the production URL. Cause: an 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: a high-severity open Firebase rules 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: a high-severity CORS misconfiguration finding. 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: FixVibe reports that anonymous visitors can read a public table even though RLS looks enabled in the dashboard. Cause: RLS is on, but a policy such as USING (true) lets the anon role through, or the migration that tightened it never ran in production. Fix: replace the permissive policy with one scoped to auth.uid(), apply the migration, and re-scan.

  5. Unsigned IDOR-walkable IDs

    Symptom: an active scan on your verified domain reports that one user can read another user's records at /api/items/1, /api/items/2, ... 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 the rest of its 230+ passive checks, usually 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

How to secure an app built with AI coding tools ยท FixVibe