← Back to blog

The 10 most common security bugs Cursor writes

·8 min read·prodlint

AI code compiles. That doesn't mean it's safe.

Cursor, Claude Code, Copilot, v0, Bolt. They all generate code that compiles, passes TypeScript, passes ESLint with zero warnings. But none of that tells you whether the code is actually production-ready. These are the 10 patterns we keep seeing in AI-generated codebases. They show up across every tool, every framework, every developer. Here they are, with real examples and fixes.

1. Hallucinated imports

AI models sometimes import packages that don't exist on npm. The code looks right, TypeScript might even infer types from the usage, but npm install never installed the package. The app crashes on startup.

Bad: package doesn't exist
import { formatCurrency } from 'currency-helpers'
import { slugify } from 'string-utils'

export function formatPrice(cents: number) {
  return formatCurrency(cents / 100)
}
prodlint output
src/lib/format.ts  CRIT  hallucinated-imports  Package 'currency-helpers' is imported but not declared in package.json  CRIT  hallucinated-imports  Package 'string-utils' is imported but not declared in package.json
Fix: use packages from package.json
import Dinero from 'dinero.js' // declared in package.json

export function formatPrice(cents: number) {
  return Dinero({ amount: cents }).toFormat('$0,0.00')
}

2. Hardcoded secret fallbacks

This one is everywhere. The AI writes process.env.JWT_SECRET with a fallback string "just in case." In development it works because the env var is set. In production, if the env var is missing for any reason, your app falls back to a secret that's committed to git and visible to anyone who reads the source.

Bad: fallback exposes secret
const jwt = new JWT({
  secret: process.env.JWT_SECRET || "my-super-secret-key",
})

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
)
prodlint output
src/lib/auth.ts  CRIT  env-fallback-secret  Secret environment variable 'JWT_SECRET' has a hardcoded fallback
Fix: fail fast if secret is missing
const secret = process.env.JWT_SECRET
if (!secret) throw new Error('JWT_SECRET is required')

const jwt = new JWT({ secret })

3. Hardcoded API keys

The AI generates example code with real-looking API keys inline. Sometimes they're placeholder strings, sometimes they're actual test keys the model saw in training data. Either way, they end up in your repo.

Bad: keys in source code
const stripe = new Stripe(
  "sk_test_51H7bmkCo5bvQHBz8kFTZM7vl8wnS4bS6CL",
  { apiVersion: "2024-12-18.acacia" }
)

const aws = new S3Client({
  credentials: {
    accessKeyId: "AKIAIOSFODNN7EXAMPLE",
    secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  },
})
prodlint output
src/lib/stripe.ts  CRIT  secrets  Stripe secret key detected in code  CRIT  secrets  AWS access key detected in code
Fix: use environment variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
})

4. Hallucinated APIs

AI models mix up JavaScript APIs with similar methods from other languages. .flatten() is from Ruby, .contains() is from Java. JavaScript uses .flat() and .includes(). The code looks reasonable, passes a quick review, and crashes at runtime.

Bad: methods that don't exist in JS
const allTags = userTags.flatten()
if (allTags.contains('admin')) {
  grantAccess()
}

const slug = title.substr(0, 50).toLowerCase()
prodlint output
src/lib/tags.ts  CRIT  hallucinated-api  '.flatten()' does not exist. Use '.flat()' instead  CRIT  hallucinated-api  '.contains()' does not exist. Use '.includes()' instead
Fix: use correct JS methods
const allTags = userTags.flat()
if (allTags.includes('admin')) {
  grantAccess()
}

const slug = title.substring(0, 50).toLowerCase()

5. Supabase tables without RLS

When AI generates Supabase migration files, it creates the table and moves on. It doesn't add ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Without RLS, anyone with your Supabase URL can read and write every row in that table. This is the single most dangerous pattern on this list.

Bad: no row-level security
-- supabase/migrations/001_init.sql
CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email text NOT NULL,
  role text DEFAULT 'user'
);

CREATE TABLE documents (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES users(id),
  content text,
  is_private boolean DEFAULT true
);
prodlint output
supabase/migrations/001_init.sql  CRIT  supabase-missing-rls  Table 'users' does not have row level security enabled  CRIT  supabase-missing-rls  Table 'documents' does not have row level security enabled
Fix: enable RLS + add policies
CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email text NOT NULL,
  role text DEFAULT 'user'
);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE TABLE documents (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES users(id),
  content text,
  is_private boolean DEFAULT true
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can read own docs"
  ON documents FOR SELECT
  USING (auth.uid() = user_id);

6. Server actions without auth

Next.js server actions run on the server but they're exposed as POST endpoints. If a server action deletes data or writes to the database without checking who's making the request, anyone can call it. The AI generates the mutation logic but skips the auth check.

Bad: no auth before mutation
'use server'

export async function deleteProject(projectId: string) {
  await db.delete(projects).where(eq(projects.id, projectId))
  revalidatePath('/dashboard')
}

export async function updateRole(userId: string, role: string) {
  await db.update(users).set({ role }).where(eq(users.id, userId))
}
prodlint output
src/app/actions.ts  CRIT  server-action-auth  Server action 'deleteProject' performs mutation without authentication check  CRIT  server-action-auth  Server action 'updateRole' performs mutation without authentication check
Fix: check auth before mutating
'use server'

export async function deleteProject(projectId: string) {
  const session = await auth()
  if (!session) throw new Error('Unauthorized')

  await db.delete(projects)
    .where(and(
      eq(projects.id, projectId),
      eq(projects.userId, session.user.id) // scope to owner
    ))
  revalidatePath('/dashboard')
}

7. Unvalidated form input in server actions

formData.get() returns unknown user input. Using it directly without validation means any string gets written to your database. The AI grabs the form field and passes it straight through.

Bad: raw formData used directly
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title')
  const body = formData.get('body')

  await db.insert(posts).values({
    title,
    body,
    authorId: session.user.id,
  })
}
prodlint output
src/app/actions.ts  CRIT  next-server-action-validation  Server action 'createPost' uses unvalidated form input
Fix: validate with Zod
'use server'

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(10000),
})

export async function createPost(formData: FormData) {
  const { title, body } = createPostSchema.parse(
    Object.fromEntries(formData)
  )

  await db.insert(posts).values({
    title,
    body,
    authorId: session.user.id,
  })
}

8. API routes without rate limiting

Every public API route is a potential abuse vector. Without rate limiting, a single user can hammer your endpoint thousands of times per second. The AI never adds rate limiting unless you specifically ask for it.

Bad: no rate limiting
// app/api/contact/route.ts
export async function POST(req: Request) {
  const { email, message } = await req.json()
  await sendEmail({ to: 'support@example.com', from: email, body: message })
  return Response.json({ sent: true })
}
prodlint output
src/app/api/contact/route.ts  INFO  rate-limiting  API route has no rate limiting
Fix: add rate limiting
// app/api/contact/route.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '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 { email, message } = await req.json()
  await sendEmail({ to: 'support@example.com', from: email, body: message })
  return Response.json({ sent: true })
}

9. API routes without error handling

AI wraps things in try/catch about half the time. The other half, your API route has zero error handling. An unexpected error returns a 500 with a stack trace that leaks internal paths and package versions.

Bad: no try/catch
// app/api/users/route.ts
export async function GET() {
  const users = await db.select().from(usersTable)
  return Response.json(users)
}

export async function POST(req: Request) {
  const body = await req.json()
  const user = await db.insert(usersTable).values(body).returning()
  return Response.json(user)
}
prodlint output
src/app/api/users/route.ts  CRIT  error-handling  API route handler 'GET' has no error handling  CRIT  error-handling  API route handler 'POST' has no error handling
Fix: wrap in try/catch
// app/api/users/route.ts
export async function GET() {
  try {
    const users = await db.select().from(usersTable)
    return Response.json(users)
  } catch (err) {
    console.error('GET /api/users failed:', err)
    return Response.json({ error: 'Internal server error' }, { status: 500 })
  }
}

10. Placeholder content left in production

Lorem ipsum, example@example.com, John Doe, password123, your-api-key-here. The AI scaffolds your app with placeholder content and you forget to replace it. Most of it is harmless. Some of it (like placeholder passwords) is not.

Bad: placeholder content ships to prod
export const siteConfig = {
  name: "My App",
  description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
  contact: "example@example.com",
  author: "John Doe",
}

export const defaultUser = {
  email: "test@test.com",
  password: "password123",
  apiKey: "your-api-key-here",
}
prodlint output
src/config.ts  WARN  placeholder-content  'Lorem ipsum' detected  WARN  placeholder-content  Placeholder email 'example@example.com' detected  WARN  placeholder-content  Placeholder name 'John Doe' detected  WARN  placeholder-content  Placeholder password 'password123' detected
Fix: use real content or env vars
export const siteConfig = {
  name: "My App",
  description: "Ship production-ready code with confidence",
  contact: process.env.CONTACT_EMAIL!,
  author: "Your Name",
}

Catch all 10 in one command

Every pattern above is caught by prodlint. Zero config, runs in under 100ms, works on any JavaScript or TypeScript project. Add it to your CI pipeline with the GitHub Action, or use the MCP server inside Cursor or Claude Code for real-time feedback while you code.

Catch all of these automatically.

52 production readiness checks. Zero config. Under 100ms.