Why This Matters
AI generates working code. It does not generate secure code by default. Every vibe coded app that handles user data needs these 10 fixes before going live. Skip any of them and you risk exposing user data, leaking API keys, or getting your Stripe account compromised.
This isn't theoretical. These are real vulnerabilities found in production vibe coded apps in 2026.
Fix 1: Move Secret Keys Out of Frontend Code
The vulnerability: AI often puts API keys in files prefixed with NEXT_PUBLIC_, which exposes them to anyone who opens browser DevTools.
Check: Search your codebase for these patterns:
grep -r "NEXT_PUBLIC_STRIPE_SECRET" src/ grep -r "NEXT_PUBLIC_SUPABASE_SERVICE" src/ grep -r "sk_live_" src/ grep -r "sk_test_" src/
If any of these return results, you have exposed keys.
- Fix: Only these variables should be
NEXT_PUBLIC_SUPABASE_URL(the project URL — this is public)NEXT_PUBLIC_SUPABASE_ANON_KEY(the anon key — designed to be public)NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY(pk_test_ or pk_live_ — designed to be public)
NEXT_PUBLIC_:
- Everything else goes in server-side code only (API routes, server components):
STRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETSUPABASE_SERVICE_ROLE_KEYRESEND_API_KEY- Any API key for external services
Fix 2: Enable Row Level Security on Every Table
The vulnerability: Without RLS, any authenticated user can read every row in your database — including other users' data.
Check: Go to Supabase Dashboard → Table Editor. If any table shows an unlocked icon, RLS is disabled.
Fix: For every table that contains user data:
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY; -- Users can only see their own rows CREATE POLICY "users_read_own" ON your_table FOR SELECT USING (auth.uid() = user_id); -- Users can only insert their own rows CREATE POLICY "users_insert_own" ON your_table FOR INSERT WITH CHECK (auth.uid() = user_id); -- Users can only update their own rows CREATE POLICY "users_update_own" ON your_table FOR UPDATE USING (auth.uid() = user_id); -- Users can only delete their own rows CREATE POLICY "users_delete_own" ON your_table FOR DELETE USING (auth.uid() = user_id);
Test: Log in as User A, create data. Log in as User B. User B should NOT see User A's data.
Fix 3: Verify Stripe Webhook Signatures
The vulnerability: Without signature verification, anyone can send fake webhook events to your /api/webhooks/stripe endpoint and grant themselves free subscriptions.
Check: Open your webhook handler. If it doesn't call stripe.webhooks.constructEvent, it's vulnerable.
Fix:
// src/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // NOT req.json()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
// Now process event.type safely
switch (event.type) {
case 'checkout.session.completed':
// Grant access
break
}
return new Response('ok')
}The critical detail: use req.text() not req.json(). JSON parsing changes the body and breaks signature verification.
Fix 4: Validate All API Route Inputs
The vulnerability: AI-generated API routes often trust input without validation. Users can send malformed data, SQL injection attempts, or absurdly large payloads.
Fix: Add Zod validation to every API route:
import { z } from 'zod'
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
dueDate: z.string().datetime().optional(),
})
export async function POST(req: Request) {
const body = await req.json()
const result = CreateTaskSchema.safeParse(body)
if (!result.success) {
return Response.json(
{ error: result.error.errors[0].message },
{ status: 400 }
)
}
// Use result.data (validated and typed)
const { title, description, dueDate } = result.data
// ... create task
}Install Zod: npm install zod
Fix 5: Add Rate Limiting to Auth Endpoints
The vulnerability: Without rate limiting, attackers can brute-force passwords by trying thousands of combinations per second.
Fix for Next.js API routes:
// Simple in-memory rate limiter
const attempts = new Map<string, { count: number; resetAt: number }>()
function isRateLimited(ip: string, limit = 5, windowMs = 60000): boolean {
const now = Date.now()
const record = attempts.get(ip)
if (!record || now > record.resetAt) {
attempts.set(ip, { count: 1, resetAt: now + windowMs })
return false
}
record.count++
return record.count > limit
}
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') || 'unknown'
if (isRateLimited(ip)) {
return Response.json(
{ error: 'Too many attempts. Try again in 1 minute.' },
{ status: 429 }
)
}
// ... handle login
}Fix 6: Never Show Raw Error Messages
The vulnerability: Stack traces, database errors, and internal paths leak information about your architecture to attackers.
Fix: Wrap all API handlers in try/catch:
export async function GET(req: Request) {
try {
// ... your logic
return Response.json({ data })
} catch (error) {
console.error('API error:', error) // Log internally
return Response.json(
{ error: 'Something went wrong. Please try again.' },
{ status: 500 }
)
}
}{
"headers": [
{
"source": "/(.<em>)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" }
]
}
]
}git log --all --full-history -- .env.local
If this returns any results, your secrets were committed at some point.
Fix:
echo ".env.local" >> .gitignore echo ".env" >> .gitignore echo ".env.production" >> .gitignore
- If secrets were already committed, rotate ALL of them:
- Generate new Supabase keys
- Generate new Stripe keys
- Generate new API keys for all services
Old keys are compromised even after you remove the file from Git.
Fix 10: Test as a Different User
The vulnerability: Many security issues only appear when two different users interact with the system.
- Test checklist:
- Create User A and User B accounts
- As User A, create some data
- As User B, try to access User A's data via: - Direct URL manipulation (
- If User B can see User A's data, your RLS policies are broken
/dashboard/user-a-id)
- API calls with User A's IDs
- Browser DevTools → Network tab → replay requests with User B's session
Pre-Launch Security Checklist
Print this and check every box before going live:
NEXT_PUBLIC_ variables.env.local in .gitignore and never committed