Same tools, same blind spots
We built prodlint because AI-generated code has predictable gaps. Not random bugs. Predictable ones. Cursor, v0, Bolt, Claude Code, Copilot. They all produce code that compiles, passes linting, and runs fine locally. But they consistently skip the same production concerns. We've been running prodlint on our own projects and on open-source repos on GitHub. The same five patterns keep showing up, across different tools, different frameworks, different developers. Here they are.
#1 Hardcoded secret fallbacks
This one shows up everywhere. The AI writes process.env.SOMETHING || "default-value" because it's trying to make the code work out of the box. Helpful in a tutorial. Dangerous in production. The problem isn't that the fallback exists during development. It's that it ships to production, sits in your git history, and silently activates if someone forgets to set the env var during deployment. JWT secrets, database URLs, API keys, Supabase anon keys. The AI does this unless you explicitly tell it not to.
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || "https://abc.supabase.co",
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "eyJhbGciOiJIUzI1NiIs..."
)
const resend = new Resend(
process.env.RESEND_API_KEY || "re_123456789"
)src/lib/supabase.ts CRIT env-fallback-secret Secret 'NEXT_PUBLIC_SUPABASE_ANON_KEY' has a hardcoded fallbacksrc/lib/email.ts CRIT env-fallback-secret Secret 'RESEND_API_KEY' has a hardcoded fallbackfunction requireEnv(name: string): string {
const val = process.env[name]
if (!val) throw new Error(`Missing required env var: ${name}`)
return val
}
const supabase = createClient(
requireEnv('NEXT_PUBLIC_SUPABASE_URL'),
requireEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
)#2 Server actions with no auth check
Next.js server actions are the worst offender here because the AI treats them like regular functions. It writes the database mutation, adds revalidatePath, and moves on. But server actions are HTTP endpoints. Anyone can call them. You don't need to be logged in, you don't need to be on your site, you just POST to the endpoint with the right payload. Delete operations, role changes, payment modifications — all without a single auth check. The AI just doesn't think about who's calling the function. It writes the happy path and stops.
'use server'
export async function deleteAccount(userId: string) {
await db.delete(accounts).where(eq(accounts.id, userId))
revalidatePath('/admin')
}
export async function changeSubscription(userId: string, plan: string) {
await db.update(users).set({ plan }).where(eq(users.id, userId))
}src/app/actions/admin.ts WARN server-action-auth 'deleteAccount' performs mutation without auth check WARN server-action-auth 'changeSubscription' performs mutation without auth check'use server'
export async function deleteAccount(userId: string) {
const session = await auth()
if (!session?.user) throw new Error('Not authenticated')
if (session.user.id !== userId) throw new Error('Forbidden')
await db.delete(accounts).where(eq(accounts.id, userId))
revalidatePath('/admin')
}#3 API routes without rate limiting
AI-generated API routes almost never include rate limiting. Contact forms, signup endpoints, webhook receivers, AI chat completions (those are expensive). All accepting unlimited requests from anyone. The scariest version of this is when someone uses an AI tool to build a wrapper around OpenAI or Anthropic's API. The AI generates a clean Next.js route that proxies the request, but it never adds rate limiting. Anybody who finds the endpoint can burn through the developer's API credits in minutes.
// app/api/chat/route.ts
export async function POST(req: Request) {
const { messages } = await req.json()
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
})
return Response.json(completion)
}src/app/api/chat/route.ts INFO rate-limiting API route has no rate limiting// app/api/chat/route.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'),
})
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) {
return Response.json({ error: 'Too many requests' }, { status: 429 })
}
const { messages } = await req.json()
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
})
return Response.json(completion)
}#4 Empty catch blocks
AI tools write try/catch when they remember to, but the catch block is often completely empty or just has a console.log that nobody reads in production. The result: errors vanish. Your app looks like it's working, but things are silently failing. Database writes that never happened, API calls that returned errors nobody saw, auth checks that threw exceptions and got swallowed. The "// handle error" comment is practically a signature of AI output at this point.
export async function processPayment(orderId: string) {
try {
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId)
})
await stripe.charges.create({
amount: order!.total,
currency: 'usd',
source: order!.paymentMethod,
})
await db.update(orders).set({ status: 'paid' }).where(eq(orders.id, orderId))
} catch (e) {
// handle error
}
}src/lib/payments.ts WARN shallow-catch Empty catch block silently swallows errorsexport async function processPayment(orderId: string) {
try {
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId)
})
if (!order) throw new Error(`Order ${orderId} not found`)
await stripe.charges.create({
amount: order.total,
currency: 'usd',
source: order.paymentMethod,
})
await db.update(orders).set({ status: 'paid' }).where(eq(orders.id, orderId))
} catch (err) {
await db.update(orders).set({ status: 'failed' }).where(eq(orders.id, orderId))
throw err // let the caller know
}
}#5 Supabase tables without RLS
This is the scariest one on the list because the consequences are total. Without Row Level Security, your Supabase anon key (which is public, it's right there in your frontend code) gives anyone full read and write access to that table. You paste the anon key and URL into a Supabase client, call .from('users').select('*'), and you get every row. Not a theoretical attack. The AI generates the CREATE TABLE statement and moves on. It doesn't add ALTER TABLE ... ENABLE ROW LEVEL SECURITY because nobody asked it to. If you're using Supabase with AI-generated migrations, check this first.
-- supabase/migrations/20240101_init.sql
CREATE TABLE profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id),
full_name text,
avatar_url text,
stripe_customer_id text
);
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sender_id uuid REFERENCES profiles(id),
recipient_id uuid REFERENCES profiles(id),
body text NOT NULL,
created_at timestamptz DEFAULT now()
);supabase/migrations/20240101_init.sql CRIT supabase-missing-rls Table 'profiles' has no row level security CRIT supabase-missing-rls Table 'messages' has no row level securityCREATE TABLE profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id),
full_name text,
avatar_url text,
stripe_customer_id text
);
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users read own profile"
ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users update own profile"
ON profiles FOR UPDATE USING (auth.uid() = id);
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sender_id uuid REFERENCES profiles(id),
recipient_id uuid REFERENCES profiles(id),
body text NOT NULL,
created_at timestamptz DEFAULT now()
);
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users read own messages"
ON messages FOR SELECT
USING (auth.uid() = sender_id OR auth.uid() = recipient_id);Why these five and not others?
AI coding tools optimize for one thing: make it work. They generate code that runs, handles the happy path, and looks clean. But production readiness isn't about making it work. It's about what happens when things go wrong, when someone malicious shows up, when an env var is missing, when a request fails. The AI skips these concerns because they're not part of the prompt. Nobody says "write me a server action and also add auth." They say "write me a server action that deletes a project." And the AI does exactly that. These five patterns are the gap between "it works" and "it's ready to ship." They show up in every AI tool because every AI tool has the same blind spot.
Run it on your own code
Every pattern in this post is caught by prodlint. One command, zero config. It checks all 52 rules and tells you exactly what to fix. If you're shipping AI-generated code, run this before you deploy. You can also check the full list of rules at prodlint.com/rules to see what else it catches beyond the five patterns here.
npx prodlint src/lib/supabase.ts CRIT env-fallback-secret Secret has a hardcoded fallback src/app/actions.ts WARN server-action-auth Server action performs mutation without auth src/app/api/chat/route.ts INFO rate-limiting API route has no rate limiting src/lib/payments.ts WARN shallow-catch Empty catch block silently swallows errors supabase/migrations/001.sql CRIT supabase-missing-rls Table has no row level security 5 files scanned — 2 critical, 2 warnings, 1 infoCatch all of these automatically.
52 production readiness checks. Zero config.