FixVibe

// docs / security guides / hardening

Så säkrar du en app byggd med AI-kodningsverktyg

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 the templated remediation prompt for each finding so you can paste it back into the IDE.

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 — paste the FixVibe AI-fix prompt for each finding back into Cursor or Claude Code. 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

Sluta läs. Hitta luckorna i din app i stället.

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.
  • AI fix prompts on every finding — paste back into your IDE.
Kör en gratis skanning

ingen registrering

Så säkrar du en app byggd med AI-kodningsverktyg — Docs · FixVibe