Why AI-built apps have predictable security holes
AI code generators optimise for working code, not secure code. They generate patterns that make features function correctly in development. Security is a property of the whole system — how it handles malformed input, who can read what data, what happens when a token is stolen — and that's exactly what AI tools miss.
The 9 issues in this checklist appear in almost every AI-generated app we rescue at Smith. They're not obscure vulnerabilities — they're the basics that get skipped when the goal is "make it work."
Go through these before you launch. Each one takes 10–30 minutes to fix.
Run the automated scan first. Before going through this checklist manually, run npx smith-check in your project directory — it scans for most of the issues below in under 10 seconds and shows you exactly which files are affected:
npx smith-checkThen use this guide to understand and fix what it flags.
1. Environment variables exposed to the client
What AI generates:
// AI often prefixes server-only secrets with NEXT_PUBLIC_
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)
const openai = new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY! })NEXT_PUBLIC_ variables are bundled into your client-side JavaScript and visible to anyone who opens DevTools. If you use them for secret keys, every visitor to your site gets your keys.
The fix:
// Server-only secrets — no NEXT_PUBLIC_ prefix
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! })How to check:
grep -r "NEXT_PUBLIC_" --include="*.ts" --include="*.tsx" . | grep -v ".env"Any result that contains STRIPE_SECRET, OPENAI, SUPABASE_SERVICE_ROLE, or similar should not have NEXT_PUBLIC_.
Rule: NEXT_PUBLIC_ is only for values that are safe to be public — your Stripe publishable key, your Supabase URL and anon key. Everything else is server-only.
2. Supabase Row Level Security disabled
What AI generates:
Supabase tables created by AI tools often have RLS disabled. With RLS off, anyone with your anon key can read, update, or delete every row in every table.
Your anon key is public — it's in your frontend JavaScript. Without RLS, your entire database is public.
How to check:
In Supabase dashboard → Table Editor → click each table → check if "Row Level Security" shows as enabled. Or run:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';Any table with rowsecurity = false is open to the world.
The fix:
Enable RLS on every table, then add policies for what's allowed:
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Users can only read and update their own profile
CREATE POLICY "users_own_profile" ON profiles
FOR ALL USING (auth.uid() = id);
-- Users can read their own orders only
CREATE POLICY "users_own_orders" ON orders
FOR SELECT USING (auth.uid() = user_id);
-- Only authenticated users can insert orders
CREATE POLICY "authenticated_can_insert_orders" ON orders
FOR INSERT WITH CHECK (auth.uid() = user_id);After enabling RLS, test that your app still works — every database operation now requires an explicit policy. If something breaks, you're missing a policy, not fighting the security.
3. Missing server-side validation
What AI generates:
// ❌ Client validates, server trusts
// app/api/post-job/route.ts
export async function POST(request: Request) {
const { title, budget, description } = await request.json()
// No validation — inserts whatever the client sent
await supabase.from('jobs').insert({ title, budget, description })
return NextResponse.json({ success: true })
}Client-side validation (form required fields, min/max values) can be bypassed by anyone who can open a terminal and run curl. An attacker can send arbitrary data directly to your API.
The fix:
Validate on the server. Use Zod — it's clean and already in most AI-generated projects:
import { z } from 'zod'
const PostJobSchema = z.object({
title: z.string().min(10).max(200),
budget: z.number().int().positive().max(100000),
description: z.string().min(50).max(5000),
})
export async function POST(request: Request) {
const body = await request.json()
const result = PostJobSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { title, budget, description } = result.data
await supabase.from('jobs').insert({ title, budget, description, user_id: userId })
return NextResponse.json({ success: true })
}Validate every field your API accepts. Don't trust the shape, type, or value of anything from the client.
4. SQL injection via string interpolation
What AI generates:
// ❌ Direct string interpolation — injectable
const { data } = await supabase
.from('jobs')
.select('*')
.filter('title', 'ilike', `%${userInput}%`)
// Or worse, raw SQL:
const { data } = await supabase.rpc('search_jobs', {
query: `SELECT * FROM jobs WHERE title LIKE '%${userInput}%'`
})The fix:
Supabase's query builder is parameterised by default — use it correctly:
// ✅ Parameterised — safe
const { data } = await supabase
.from('jobs')
.select('*')
.ilike('title', `%${userInput}%`) // Supabase escapes this internallyFor raw SQL (avoid where possible), always use parameterised queries:
// ✅ Named parameters — safe
const { data } = await supabase.rpc('search_jobs', {
search_query: userInput // passed as a parameter, not interpolated
})
-- In Supabase SQL editor, the function uses $1 not string concat:
CREATE FUNCTION search_jobs(search_query text)
RETURNS TABLE(id uuid, title text) AS $$
SELECT id, title FROM jobs WHERE title ILIKE '%' || search_query || '%'
$$ LANGUAGE sql SECURITY DEFINER;5. Using the service role key in client-side code
What AI generates:
Sometimes AI tools use SUPABASE_SERVICE_ROLE_KEY (which bypasses all RLS policies) in places where the anon key should be used — or worse, exposes it as NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY.
How to check:
grep -r "SERVICE_ROLE" --include="*.ts" --include="*.tsx" . | grep -v "server.ts\|route.ts\|api/"If the service role key appears outside of server-only files, you have a problem.
The fix:
SUPABASE_SERVICE_ROLE_KEY→ only in server-side code: API routes, server actions, webhook handlersNEXT_PUBLIC_SUPABASE_ANON_KEY→ client components and server components (it's safe, protected by RLS)- Never prefix the service role key with
NEXT_PUBLIC_
6. No rate limiting on API routes
What AI generates:
Open API routes with no rate limiting. Anyone can call your /api/send-email, /api/generate, or /api/post-job endpoints thousands of times per minute, running up your costs or degrading your service.
The fix:
The fastest option is Vercel's Rate Limiting middleware if you're on a Pro plan. For everyone else, use Upstash Redis with their rate limit SDK:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
})
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
const { success, limit, remaining } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
}
}
)
}
// ... handle request
}Apply rate limiting to: email sending routes, AI generation endpoints, authentication endpoints, and any route that costs money per call.
7. Insecure direct object references (IDOR)
What AI generates:
// ❌ Takes an ID from the URL, fetches without checking ownership
export async function GET(request: Request, { params }: { params: { id: string } }) {
const { data } = await supabase
.from('orders')
.select('*')
.eq('id', params.id)
.single()
return NextResponse.json(data) // Returns ANY order if you know the ID
}With this code, anyone can access any order by guessing or incrementing IDs. This is IDOR — one of the most common and exploited vulnerabilities.
The fix:
Always scope queries to the authenticated user:
export async function GET(request: Request, { params }: { params: { id: string } }) {
const supabase = await createServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('orders')
.select('*')
.eq('id', params.id)
.eq('user_id', user.id) // ← Scope to this user
.single()
if (error || !data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(data)
}With RLS enabled (issue #2 above), this is double-protected — but always be explicit in server code too.
8. Secrets in git history
What AI generates:
AI tools sometimes suggest adding actual values to .env.example or hardcoding keys in config files during development. These end up committed.
How to check:
# Check if .env or .env.local is tracked
git ls-files | grep ".env"
# Search commit history for secrets (replace with your actual key prefixes)
git log --all --full-history -- ".env*"
git grep -i "sk_live\|sk_test\|whsec_\|service_role" $(git log --all --oneline | awk '{print $1}')If you find secrets in history, rotation first, then history rewrite. Rotation is the priority — assume the secret has been seen.
Prevention:
# .gitignore — make sure these are listed
.env
.env.local
.env.production
.env*.local9. No CSRF protection on state-mutating routes
What AI generates:
API routes that mutate data (delete, update, purchase) with no CSRF protection. A malicious site can trigger these routes on behalf of a logged-in user.
The quick fix for Next.js App Router:
Next.js App Router's Server Actions have CSRF protection built in. For API routes, check the Origin header:
export async function POST(request: Request) {
const origin = request.headers.get('origin')
const host = request.headers.get('host')
// Block cross-origin requests to state-mutating endpoints
if (origin && !origin.includes(host ?? '')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// ... proceed
}For anything involving payments or account deletion, add an explicit CSRF token using a library like csrf-csrf.
The checklist
Before shipping:
- Run
npx smith-check— fix anything flagged as critical or high - No
NEXT_PUBLIC_prefix on secret keys - RLS enabled on all Supabase tables with explicit policies
- Every API route validates input with Zod or similar
- No string-interpolated SQL — all queries parameterised
- Service role key only in server-side files
- Rate limiting on expensive/abusable routes
- All data queries scoped to
auth.uid()/ authenticated user -
.envand.env.localin.gitignore, not in git history - CSRF protection on state-mutating routes
- Re-run
npx smith-check— score should be 90+ before launch
A security audit that catches all of this typically takes a Smith 4–6 hours. If you want professional eyes on it before launch, post a Security Audit job on Smith.