Blog

Vibe coding security: the risks and how to review the code

How to spot the security flaws that slip into AI-written code nobody read, and how to keep vibe coding without shipping them.

Alex Mercer

The security risk of vibe coding is that code nobody reads ships with the flaws AI models write: secret keys compiled into the browser bundle, API routes that never check who is calling, queries built from strings, insecure defaults and packages nobody vetted. That may be acceptable for a throwaway prototype, but once an app has real users, every change needs review, tests and automated checks before it merges.

In SusVibes, a benchmark presented at ICML 2026, coding agents worked on 186 feature requests from real open-source projects where human developers had once committed a vulnerable implementation. With SWE-Agent and Claude 4 Sonnet, 57% of the solutions were functionally correct and only 11.8% were secure. The AI code security guide covers the wider research on flaws in AI-written code.

What vibe coding is

Andrej Karpathy named it in a post on X on February 2, 2025: "There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists." In the same post he wrote, "I 'Accept All' always, I don't read the diffs anymore." Merriam-Webster's slang entry defines vibe coding as "writing computer code in a somewhat careless fashion, with AI assistance."

The risk comes from skipping the diff, which is where someone would notice a key in the wrong file or a query with no ownership check. Studies of AI-assisted coding, all run on models now more than a year old, show what gets through:

  • Perry et al. (ACM CCS 2023) ran a user study with 47 participants and an assistant built on OpenAI's codex-davinci-002. Those with the assistant wrote significantly less secure code and were more likely to believe it was secure. On the SQL task, 36% of them wrote code vulnerable to SQL injection, against 7% of the control group.

  • BaxBench (ICML 2025) gave models 392 backend tasks. The best, OpenAI o1, wrote correct code 62% of the time, and on average the authors could exploit about half of each model's correct programs.

  • Shukla et al. (IEEE ISTAS 2025) had GPT-4o "improve" 10 secure C and Java programs for 10 rounds with no human in between. Critical vulnerabilities rose 37.6% after five rounds.

Vibe coding security risks and what they look like in a diff

Each risk maps to CWE entries and OWASP Top 10 2025 categories, which the OWASP Top 10 guide walks through as they show up in pull requests.

Risk

What it looks like in a diff

OWASP Top 10 2025

CWE

Secrets in code and client bundles

A key in source, or a secret renamed with NEXT_PUBLIC_ or VITE_

A07, A01

798, 540

Missing authentication and authorization

A route or table with no session or owner check

A01, A07

862, 639, 306

Injection

SQL or a shell command built from strings

A05

89, 78

Insecure defaults

origin: true in CORS, debug=True, verify=False

A02, A07

942, 489, 295, 1188

Missing input validation

A request body passed straight to the database

A05, A08

20, 915

Hallucinated or typosquatted packages

A new dependency nobody on the team chose

A03

1357

Outdated dependencies

An old version pin, or a lockfile that resolves to one

A03

1104, 1395

Secrets in code and client bundles

Next.js inlines environment variables that start with NEXT_PUBLIC_ into the JavaScript it sends to the browser, and in client code it replaces variables without the prefix with an empty string. When a Client Component calls a paid API with a key from an unprefixed variable, the request fails because the key is empty, and the quickest fix is a rename:

 // app/contact/contact-form.tsx ('use client')
-      headers: { Authorization: `Bearer ${process.env.MAILER_KEY}` },
+      headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_MAILER_KEY}` },
 // app/contact/contact-form.tsx ('use client')
-      headers: { Authorization: `Bearer ${process.env.MAILER_KEY}` },
+      headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_MAILER_KEY}` },
 // app/contact/contact-form.tsx ('use client')
-      headers: { Authorization: `Bearer ${process.env.MAILER_KEY}` },
+      headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_MAILER_KEY}` },

The form works, and every visitor can read the key. OWASP's Next.js cheat sheet says to use the prefix "only when its value is intentionally public." Vite does the same with VITE_, and its docs say "VITE_* variables should not contain sensitive information such as API keys."

Keep the call on the server. Importing server-only makes the build fail if a Client Component imports the module:

// lib/mailer.ts
import 'server-only'

export async function sendContactMessage(message: string) {
  const res = await fetch(`${process.env.MAILER_API_URL}/send`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MAILER_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ message }),
  })
  if (!res.ok) throw new Error(`Mailer returned ${res.status}`)
}
// lib/mailer.ts
import 'server-only'

export async function sendContactMessage(message: string) {
  const res = await fetch(`${process.env.MAILER_API_URL}/send`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MAILER_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ message }),
  })
  if (!res.ok) throw new Error(`Mailer returned ${res.status}`)
}
// lib/mailer.ts
import 'server-only'

export async function sendContactMessage(message: string) {
  const res = await fetch(`${process.env.MAILER_API_URL}/send`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MAILER_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ message }),
  })
  if (!res.ok) throw new Error(`Mailer returned ${res.status}`)
}

Call it from a Server Action or Route Handler. If the key already reached a deployed bundle or a commit, rotate it first; GitHub's docs note that copies persist in clones, forks and cached views.

Missing authentication and authorization checks

This route handler returns any invoice to anyone who has its ID:

+// app/api/invoices/[id]/route.ts
+export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params
+  const invoice = await db.invoice.findUnique({ where: { id } })
+  return Response.json(invoice)
+}
+// app/api/invoices/[id]/route.ts
+export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params
+  const invoice = await db.invoice.findUnique({ where: { id } })
+  return Response.json(invoice)
+}
+// app/api/invoices/[id]/route.ts
+export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params
+  const invoice = await db.invoice.findUnique({ where: { id } })
+  return Response.json(invoice)
+}

It passes every test you run as yourself. OWASP's API Security Top 10 ranks this first, as broken object level authorization: every endpoint that receives an object ID "should implement object-level authorization checks." Check the session, then scope the query to the caller:

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await getSession() // your app's auth helper
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { id } = await params
  const invoice = await db.invoice.findFirst({
    where: { id, organizationId: session.organizationId },
  })
  if (!invoice) return Response.json({ error: 'Not found' }, { status: 404 })
  return Response.json(invoice)
}
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await getSession() // your app's auth helper
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { id } = await params
  const invoice = await db.invoice.findFirst({
    where: { id, organizationId: session.organizationId },
  })
  if (!invoice) return Response.json({ error: 'Not found' }, { status: 404 })
  return Response.json(invoice)
}
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await getSession() // your app's auth helper
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { id } = await params
  const invoice = await db.invoice.findFirst({
    where: { id, organizationId: session.organizationId },
  })
  if (!invoice) return Response.json({ error: 'Not found' }, { status: 404 })
  return Response.json(invoice)
}

Server Actions need the same check: OWASP's Next.js cheat sheet says to "Treat each reachable action as a client-callable POST entry point."

When the browser queries the database directly, as Supabase apps do with a publishable key, authorization lives in Row Level Security. Supabase's docs say a table in an exposed schema without RLS "is readable and writable by any role with a grant on it." Enable it and add policies:

alter table public.notes enable row level security;

create policy "Users can read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id )

alter table public.notes enable row level security;

create policy "Users can read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id )

alter table public.notes enable row level security;

create policy "Users can read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id )

Injection

Perry et al. noted that the assistant in their study "often writes correct SQL queries but uses string concatenation instead of prepared statements." In Python, the same mistake looks like this:

+def find_user(cur, email):
+    cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
+    return cur.fetchone()
+def find_user(cur, email):
+    cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
+    return cur.fetchone()
+def find_user(cur, email):
+    cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
+    return cur.fetchone()

Let the driver bind the values instead. In psycopg, that means %s placeholders and a sequence of values as the second argument:

def find_user(cur, email):
    cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
    return cur.fetchone()
def find_user(cur, email):
    cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
    return cur.fetchone()
def find_user(cur, email):
    cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
    return cur.fetchone()

For shell commands, Node's docs warn against passing unsanitized user input to exec, which runs a shell; execFile skips the shell by default and takes input as arguments. OWASP's preferred fix covers both: "use a safe API, which avoids using the interpreter entirely, provides a parameterized interface, or migrates to Object Relational Mapping Tools (ORMs)."

Insecure defaults

Watch for lines like these in a diff:

 // server.js (Express)
+app.use(cors({ origin: true, credentials: true }))

 # app.py (Flask)
+app.run(debug=True)
+resp = requests.get(url, verify=False)
 // server.js (Express)
+app.use(cors({ origin: true, credentials: true }))

 # app.py (Flask)
+app.run(debug=True)
+resp = requests.get(url, verify=False)
 // server.js (Express)
+app.use(cors({ origin: true, credentials: true }))

 # app.py (Flask)
+app.run(debug=True)
+resp = requests.get(url, verify=False)

With origin: true, the cors middleware reflects whatever origin the request sends, and OWASP lists a CORS misconfiguration that "allows API access from unauthorized or untrusted origins" under Broken Access Control. Flask's debugger "allows executing arbitrary Python code from the browser," and its docs say not to run it in production. With verify=False, requests "will accept any TLS certificate presented by the server," and its docs warn that this makes the application vulnerable to man-in-the-middle attacks. Name the origins you trust, and keep the other two out of production:

app.use(cors({ origin: [process.env.APP_ORIGIN], credentials: true }))
app.use(cors({ origin: [process.env.APP_ORIGIN], credentials: true }))
app.use(cors({ origin: [process.env.APP_ORIGIN], credentials: true }))

Missing input validation

An update handler that passes the request body straight to the ORM lets the client write any column, such as role or plan:

+export async function PATCH(req: Request) {
+  const session = await getSession()
+  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
+  const body = await req.json()
+  const user = await db.user.update({ where: { id: session.userId }, data: body })
+  return Response.json(user)
+}
+export async function PATCH(req: Request) {
+  const session = await getSession()
+  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
+  const body = await req.json()
+  const user = await db.user.update({ where: { id: session.userId }, data: body })
+  return Response.json(user)
+}
+export async function PATCH(req: Request) {
+  const session = await getSession()
+  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
+  const body = await req.json()
+  const user = await db.user.update({ where: { id: session.userId }, data: body })
+  return Response.json(user)
+}

That is improper input validation (CWE-20) and mass assignment (CWE-915). Parse the body with a schema that names the allowed fields. Zod strips unrecognized keys by default, so a role field never reaches the database:

import { z } from 'zod'

const UpdateProfile = z.object({
  name: z.string().trim().min(1).max(100),
  bio: z.string().max(500).optional(),
})

export async function PATCH(req: Request) {
  const session = await getSession()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const parsed = UpdateProfile.safeParse(await req.json().catch(() => null))
  if (!parsed.success) return Response.json({ error: 'Invalid request body' }, { status: 400 })

  const user = await db.user.update({
    where: { id: session.userId },
    data: parsed.data,
    select: { name: true, bio: true },
  })
  return Response.json(user)
}
import { z } from 'zod'

const UpdateProfile = z.object({
  name: z.string().trim().min(1).max(100),
  bio: z.string().max(500).optional(),
})

export async function PATCH(req: Request) {
  const session = await getSession()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const parsed = UpdateProfile.safeParse(await req.json().catch(() => null))
  if (!parsed.success) return Response.json({ error: 'Invalid request body' }, { status: 400 })

  const user = await db.user.update({
    where: { id: session.userId },
    data: parsed.data,
    select: { name: true, bio: true },
  })
  return Response.json(user)
}
import { z } from 'zod'

const UpdateProfile = z.object({
  name: z.string().trim().min(1).max(100),
  bio: z.string().max(500).optional(),
})

export async function PATCH(req: Request) {
  const session = await getSession()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const parsed = UpdateProfile.safeParse(await req.json().catch(() => null))
  if (!parsed.success) return Response.json({ error: 'Invalid request body' }, { status: 400 })

  const user = await db.user.update({
    where: { id: session.userId },
    data: parsed.data,
    select: { name: true, bio: true },
  })
  return Response.json(user)
}

The select also stops the handler from returning the whole user record. Validate on the server even when the browser checks the same fields, as OWASP's Input Validation Cheat Sheet advises.

Hallucinated and typosquatted packages

Models invent package names and repeat them. Spracklen et al. (USENIX Security 2025) generated 576,000 Python and JavaScript code samples with 16 models, and 19.7% of the 2.23 million package references named packages that don't exist. When the authors re-ran a prompt that had produced a hallucination 10 times, 43% of the invented names came back every time.

A name that comes back that reliably is one an attacker can register. OWASP's NPM Security Cheat Sheet calls this slopsquatting, an attack that "exploits AI coding assistants," and lists it beside typosquatting, which relies on typos. Either way, the diff shows one new line in package.json or requirements.txt. Check the package before you install it:

npm
npm
npm

A package that doesn't exist returns E404. OWASP also says to distrust very new packages, check download counts and the linked repository, and run npm view as a CI check.

Outdated dependencies

Models also reach for outdated APIs. In Wang et al. (ICSE 2025), seven models, from small open code models to GPT-3.5, completed functions that call eight popular Python libraries, and all of them used deprecated APIs, which the authors trace to deprecated usage in training data. In a diff, look for an old version pin, or a lockfile change that resolves to one:

   "dependencies": {
+    "lodash": "4.17.15",
   "dependencies": {
+    "lodash": "4.17.15",
   "dependencies": {
+    "lodash": "4.17.15",

OSV lists a high-severity prototype pollution advisory (CVE-2020-8203) for lodash before 4.17.19, and later advisories fixed in newer releases. Components that are "vulnerable, unsupported, or out of date" fall under OWASP's Software Supply Chain Failures category. Install from the lockfile and fail the build on known advisories:

- run: npm ci
- run

- run: npm ci
- run

- run: npm ci
- run

npm ci fails on a lockfile that doesn't match package.json instead of updating it, and --audit-level sets the severity at which npm audit fails.

How security bug rates differ between AI models

Bug Bench ranks AI coding models by relative bug rate: counted bugs per AI-attributed line in code reviewed by cubic, divided by the rate across all AI-attributed lines in the window, so 1.00 is average and lower is better. A counted bug is a cubic review finding on AI-written code that a developer upvoted or an AI coding agent addressed.

Across all bug types, the seven ranked models range from 0.65 for GPT-5.6 Sol (35% fewer counted bugs than average) to 1.50 for Claude Opus 5.5 (50% more). Neither is statistically tied with another model, because each one's 95% confidence interval overlaps no other model's. For security and privacy bugs alone, measured against the all-model rate for that bug type, GPT-5.6 Sol has the lowest rate (0.56, 44% below average) and Claude Opus 5.5 the highest (1.50, 50% above), statistically tied with Claude Sonnet 5.5 and Claude Opus 5. Bug Bench is observational, not causal: teams choose their models, so a rate reflects the code each model was used for as well as the model.

Vibe coding best practices for shipping safely

Before the agent writes code

AGENTS.md is "a simple, open format for guiding coding agents," and its suggested sections include security considerations. Put your rules there:




Two of cubic's rule pages match this list, and each includes instructions to paste into AGENTS.md or Cursor rules so your AI coding tool follows them while it writes code. Keep Secrets and Server-Only Code Out of Next.js Client Bundles catches secrets behind NEXT_PUBLIC_, server modules imported into Client Components, and full database records or tokens passed to the browser. Require API Auth, Validation, and Tenant Scoping flags API endpoints that skip auth, trust client-supplied tenant or account IDs, accept unvalidated request bodies, or leak internal errors.

With each new endpoint, ask for a test where a second user requests the first user's record and gets a 404. OWASP's API Security Top 10 says to write such tests and "Do not deploy changes that make the tests fail."

Security checklist before you merge

Read the diff even when the app works in the browser:

  1. Search it for NEXT_PUBLIC_, VITE_, .env files and anything that looks like a key or token.

  2. For each new route, Server Action or table, find the session check and the ownership filter.

  3. Look for SQL, shell commands or HTML built from strings.

  4. Check that request bodies pass through a schema before any database write.

  5. Run npm view on each new dependency and read the lockfile changes.

  6. Check config changes for CORS origins, debug flags and TLS verification.

For a full review order, see how to review AI-generated code.

What to automate in CI

Set these up in CI and in the repository settings:

  • Push protection, which blocks pushes that contain secrets such as tokens. It's on by default for your account on public repositories, and enabling it for a repository requires GitHub Secret Protection.

  • Dependabot alerts and the dependency review action, which flags added or updated dependencies with known vulnerabilities and fails the check by default.

  • npm ci, npm audit --audit-level=high, a SAST scanner such as CodeQL or Semgrep, and a linter with security rules.

  • Branch protection that requires those checks and an approving review.

  • AI code review on every pull request. It reads the diff along with the code around it and comments on lines that look wrong, usually with a suggested fix.

When a person has to read the code

OWASP's Secure Code Review Cheat Sheet says manual review adds the most in "business logic validation, complex security implementations, and context-specific vulnerabilities." The supply chain category of the OWASP Top 10 also asks for a second person: "No single person should be able to write code and promote it all the way to production without oversight from another human being." A solo developer accepting an agent's diffs is that single person.

NIST SP 800-218 (task PW.7.1) leaves each organization to decide where to use code review, in which "a person looks directly at the code to find issues," and where to use code analysis by tools. For a vibe-coded app, have a person read every change to authentication, sessions, access rules such as RLS policies, payments and personal data, and let tools and AI review cover the rest. The secure code review checklist lists what that read should cover.

FAQ

Is vibe coding bad?

It depends on what you ship. Karpathy called it "not too bad for throwaway weekend projects." Measured security effects also vary by task: in a USENIX Security 2023 study where 58 students wrote a C linked list with an early Codex model, AI-assisted users produced critical security bugs at a rate no more than 10% above the control group, a much smaller gap than Perry et al. found for SQL injection.

How do I secure an app I already vibe coded?

Start with what's already public. Search the deployed app's JavaScript in browser DevTools for key prefixes and tokens, and rotate anything you find. In Supabase, check RLS on every table; its docs say to "Enable RLS on every table in an exposed schema." Then call each API route signed out and as a second user, and fix anything that returns data it shouldn't.

Which API keys are safe to put in a vibe-coded frontend?

Only keys the provider documents as public. Supabase's publishable key (the legacy anon key) is meant for code you ship: "Anyone can read it, so it only reaches what Row Level Security allows." Its secret key (the legacy service_role key) "bypasses every Row Level Security policy you have," and Supabase says never to put one in a browser, a shipped application or source control.

Does telling the AI to write secure code fix it?

Not on its own. In SusVibes, adding vulnerability hints to the feature request did not fix the security issues. Shukla et al. had GPT-4o rework code under four kinds of prompts: security-focused prompts introduced the fewest vulnerabilities, 38 against 158 for feature-focused prompts, and cryptographic mistakes were the largest share of those 38 (21.1%). Perry et al. found that participants who trusted the assistant less and reworked their prompts wrote code with fewer vulnerabilities.

Table of contents