Vibe coding — describing what you want to an AI and letting it write the code — is now how a large share of software gets built. Cursor, Claude Code, Copilot and Windsurf take you from idea to deployed app in an afternoon.
The security problem is not that AI writes bad code. It usually writes clean, idiomatic, well-structured code. The problem is that it writes exactly what you asked for and nothing you did not ask for. "Create an endpoint to delete a user" produces an endpoint that deletes a user. It does not produce an authorization check, because you did not ask for one, and the result looks finished.
These are the seven patterns that show up most often, each with the code an assistant typically produces and the version you actually want.
Why AI-generated code fails this way
Three mechanics explain nearly every finding below.
Training data is example code. Tutorials, blog posts and README snippets omit auth, validation and error handling to stay readable. That is the distribution the model learned the shape of a "correct answer" from.
The prompt defines the scope. A model completing "search users by name" is being scored, implicitly, on whether searching works. Nothing in the request mentions injection, so nothing in the output addresses it.
Working code ends the conversation. When the feature works, you move on. There is no step where anything asks what else the code now permits.
None of this is fixed by better prompting alone. It is fixed by a check that runs after the code exists.
1. Hardcoded secrets
The most common finding by a wide margin. Assistants fill configuration with plausible, real-looking values.
// AI-generated config
const stripe = require('stripe')('sk_live_51ABC...');
const db = new Pool({ connectionString: 'postgresql://admin:pass@db.host/prod' });// What you want
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const db = new Pool({ connectionString: process.env.DATABASE_URL });
if (!process.env.STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY is not set');Fail loudly on a missing variable. A silent undefined becomes a confusing runtime error later, and the temptation is to paste the literal back in "just to test".
If a real key ever reached a commit, rotate it. Removing it in a later commit does not remove it from history, and scrapers watch public pushes within seconds.
2. Endpoints without authorization
The model writes the logic and omits the gate.
// AI-generated: "create an API endpoint to delete a user"
export async function DELETE(req: Request) {
const { userId } = await req.json();
await db.user.delete({ where: { id: userId } });
return Response.json({ success: true });
}Anyone who can reach the URL can delete anyone.
// What you want
import { auth } from '@/lib/auth';
export async function DELETE(req: Request) {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const { userId } = await req.json();
// Authentication is not authorization: prove this user may delete this user.
if (session.user.id !== userId && session.user.role !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
await db.user.delete({ where: { id: userId } });
return Response.json({ success: true });
}The distinction that matters: *authenticated* means we know who you are. *Authorized* means you may do this specific thing to this specific record. AI-generated code that has any check at all usually has only the first.
3. String-interpolated SQL
Assistants reach for raw queries when the filtering gets complex enough that an ORM feels awkward.
# AI-generated: "search users by name"
@app.route('/search')
def search():
name = request.args.get('name')
results = db.execute(f"SELECT * FROM users WHERE name LIKE '%{name}%'")
return jsonify(results)?name=%' OR '1'='1 returns the table.
# What you want
@app.route('/search')
def search():
name = request.args.get('name', '')
results = db.execute(
"SELECT id, name, email FROM users WHERE name LIKE %s LIMIT 50",
(f"%{name}%",),
)
return jsonify(results)Three changes: the parameter is bound rather than interpolated, the columns are named instead of SELECT *, and there is a LIMIT. The last two matter even without an injection — SELECT * on a users table is how password hashes end up in a JSON response.
4. Mass assignment through unvalidated input
The subtle one. A form handler that passes the request body straight to the database.
// AI-generated: "let users update their profile"
export async function updateProfile(formData: FormData) {
const data = Object.fromEntries(formData);
await db.user.update({ where: { id: session.user.id }, data });
}The form shows name and bio. The endpoint accepts whatever is posted — including role: "admin", plan: "enterprise", or emailVerified: true.
// What you want
import { z } from 'zod';
const ProfileUpdate = z.object({
name: z.string().min(1).max(80),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const data = ProfileUpdate.parse(Object.fromEntries(formData));
await db.user.update({ where: { id: session.user.id }, data });
}A schema is an allowlist. Anything not named is dropped, so a field added to the database later is not automatically writable from the internet.
5. Excessive agency in AI features
When the thing you are vibe coding is itself an AI feature, assistants hand the model far more capability than the task needs.
// AI-generated: "give the assistant access to our database"
const tools = [
{ name: 'query_database', fn: (sql: string) => db.$queryRawUnsafe(sql) },
{ name: 'run_command', fn: (cmd: string) => exec(cmd) },
{ name: 'write_file', fn: (p: string, c: string) => fs.writeFileSync(p, c) },
];Arbitrary SQL, shell and filesystem access, driven by text that may contain content from your users. That is prompt injection with a direct path to your infrastructure.
// What you want
const tools = [
{
name: 'get_order_status',
// A named operation with typed parameters, not an SQL string.
fn: async ({ orderId }: { orderId: string }) => {
const order = await db.order.findFirst({
where: { id: orderId, customerId: session.user.id },
select: { status: true, shippedAt: true },
});
return order ?? { error: 'not found' };
},
},
];The rule: give the model named operations, not primitives. get_order_status cannot be talked into dropping a table. query_database can. Anything destructive belongs behind an explicit human approval step.
This is OWASP LLM Top 10 territory — see MCP Security Is Becoming the New API Security for how the same problem appears in tool servers.
6. Containers running as root
Generated Dockerfiles almost never drop privileges.
# AI-generated
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]Runs as root, includes the full base image, and copies your .env and .git if they are not excluded.
# What you want
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]Add a .dockerignore with .env, .git, and node_modules. Copying .git into an image ships your entire commit history — including any secret ever committed and later removed.
7. Unpinned dependencies
{ "dependencies": { "express": "*", "lodash": "^4.0.0" } }A wildcard means your next npm install can pull a version nobody has reviewed. This is the attack surface that CanisterWorm used to spread across 141 package versions — a malicious patch release, installed automatically by a permissive range.
{ "dependencies": { "express": "4.21.2", "lodash": "4.17.21" } }Commit the lockfile. Use npm ci in CI rather than npm install, so the build uses exactly what was reviewed. Under the OWASP Top 10:2025 this now sits in A03 Software Supply Chain Failures, which was broadened specifically to cover this.
The fix: one command after every session
None of this argues against vibe coding. It argues for a review step that is not another human reading generated code at the speed it now arrives.
npx ship-safe audit .Scans for all seven patterns above plus roughly a hundred more, ranked by severity, with the OWASP category and fix on each. No account or API key required.
To catch it before it merges rather than after, Ship Safe reviews pull requests automatically — deterministic findings plus a Kimi K3 read of the diff, ten reviews free per account.
FAQ
Is vibe coding safe?
It is safe to the extent that the code is reviewed, and unsafe to the extent that it is not. AI assistants produce code that works and systematically omit the controls nobody asked for — authorization checks, input validation, privilege dropping, dependency pinning. The speed is not the danger; the missing review step is. A scan after each session closes most of the gap.
What are the security risks of vibe coding?
Seven appear repeatedly, in roughly this order: hardcoded secrets, endpoints missing authorization, string-interpolated SQL, mass assignment from unvalidated input, over-permissioned AI tool definitions, containers running as root, and unpinned dependency ranges. Each is covered above with the vulnerable code and the fix.
How do I secure a vibe coded app?
Run npx ship-safe audit . in the project root after each session. It reports the seven patterns above plus roughly a hundred more, ranked by severity with the OWASP category on each. Add --threshold 70 in CI to fail a build below a score, so the check runs without anyone remembering to run it.
Is AI-generated code secure?
It is usually syntactically correct and often well-structured, but it systematically omits controls that were not requested — authorization checks, input validation, privilege dropping, dependency pinning. The failure is one of scope rather than quality: the model answers the question asked, and security requirements are rarely part of the question.
What are the most common security issues in AI-generated code?
In order of how often they appear: hardcoded secrets, endpoints missing authorization, string-interpolated SQL, mass assignment from unvalidated input, over-permissioned AI tool definitions, containers running as root, and unpinned dependency ranges.
Can I just ask the AI to write secure code?
It helps and it is not sufficient. Asking for validation gets you validation; it does not get you the check you did not know to request. Prompting improves the average case, while the risk lives in the case you did not think of — which is exactly what an automated scan covers.
Does vibe coding introduce different vulnerabilities than hand-written code?
The categories are the same — they are the OWASP Top 10. What differs is the rate and the distribution. Code arrives faster than it can be reviewed, and omissions cluster in the same places every time because they come from the same training distribution. That consistency is useful: it means automated scanning catches a large share of it.
How do I check a vibe-coded project for security issues?
Run npx ship-safe audit . in the project root. It reports findings by severity with OWASP categories and fixes. Add --threshold 70 in CI to fail a build below a score.
Related reading
- How to Secure Your Next.js App: A Complete Guide — the framework-specific version of these mistakes
- OWASP Top 10:2025: What Changed — where each of these sits in the current standard
- MCP Security Is Becoming the New API Security — excessive agency, in depth
- From Trivy to CanisterWorm — what an unpinned dependency actually costs
Ship fast. Ship safe.