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 repos people submit through the site scanner. The same five patterns keep coming up. Not occasionally. Constantly. Across different tools, different frameworks, different developers. Here they are, ranked by how often we see them.
#1 Hardcoded secret fallbacks
This is the single most common pattern prodlint catches. 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. We see this with JWT secrets, database URLs, API keys, Supabase anon keys. The AI does it every time 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. We regularly see delete operations, role changes, and payment modifications 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 CRIT server-action-auth 'deleteAccount' performs mutation without auth check CRIT 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
Almost every AI-generated API route we see has zero 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. We see this pattern constantly in our own AI-generated code too. 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 CRIT error-handling 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. Every Supabase project we've run prodlint on has had at least one table missing RLS.
-- 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, runs in under 100ms. 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 CRIT 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 CRIT error-handling Empty catch block silently swallows errors supabase/migrations/001.sql CRIT supabase-missing-rls Table has no row level security 5 files scanned — 4 critical, 1 infoCatch all of these automatically.
52 production readiness checks. Zero config. Under 100ms.