Why Vibe Coding Creates Brittle and Vulnerable Systems
Try the interactive lab for this articleTake the quiz (6 questions)Prompting large language models to write software iterations based on qualitative user experience rather than formal specification, state machine modeling, or rigorous static verification has become known as vibe coding. A developer or product builder describes desired behavior in natural language, accepts the generated output when it produces a visually working interface or passes a superficial happy-path test, and prompts repeatedly until the feature appears complete. The feedback loop relies on sensory validation: the browser renders the expected component, the HTTP request returns a 200 OK status code, and the interface responds smoothly under light manual interaction.
While this development pattern accelerates initial prototyping, it introduces structural degradation into production software. Large language models optimize for token probability distribution over training corpora, producing code that exhibits high local syntactic plausibility. The syntax is clean, modern, and idiomatic at a single-function level. However, autoregressive language models operate without a global execution state or an architectural mental model. They do not trace state transitions across asynchronous boundaries, enforce thread-safety invariants, evaluate memory ownership semantics, or model adversarial input vectors.
The resulting code bases suffer from systematic brittleness. Beneath the polished exterior lies deep architectural debt, missing defensive guardrails, silent concurrency race conditions, supply chain vulnerabilities originating from hallucinated dependencies, and tautological test suites that validate flawed assumptions. When exposed to concurrent production loads, edge-case input distributions, network degradation, or targeted exploit payloads, vibe-coded software fails catastrophically.
The Vibe Coding Fallacy: Syntactic Plausibility vs. Structural Integrity
The core vulnerability of vibe coding is the confusion of syntactic plausibility with structural correctness. A software system is not merely a collection of valid functions; it is a state machine executing across memory boundaries, network interfaces, and persistent storage layers under unpredictable environmental conditions.
When an engineer designs a system manually, the implementation code is downstream of a mental model that accounts for data ownership, failure modes, state lifecycles, and security boundaries. When an LLM generates code, the process operates in reverse: text probability models synthesize code fragments that match patterns found in training data. The model does not understand why a particular locking mechanism is necessary or what happens to an unclosed socket stream when an underlying database connection times out.
+-----------------------------------------------------------------------+
| MANUAL ENGINEERING FLOW |
| |
| [System Specs] -> [State Machine Model] -> [Threat & Failure Analysis]|
| | |
| v |
| [Implementation Code] |
+-----------------------------------------------------------------------+
+-----------------------------------------------------------------------+
| VIBE CODING FLOW |
| |
| [Natural Language Prompt] -> [LLM Token Probability Prediction] |
| | |
| v |
| [Syntactic Output] |
| | |
| v |
| [Sensory Validation (Passes)] |
| | |
| v |
| [Hidden Structural Debt & Bugs] |
+-----------------------------------------------------------------------+Language models generate code that mimics high-quality open-source projects. Variable names follow modern conventions, async/await syntax is formatted cleanly, and popular helper libraries are imported correctly. This creates an optical illusion of quality. A developer reviewing the PR sees clean TypeScript, Go, or Rust code with explicit type annotations and modern functional patterns.
However, local coherence frequently conceals global architectural collapse. The model treats each function as an isolated text completion task based on the context window payload. It lacks the capacity to verify system-wide invariants, such as:
- Transactional atomicity: Ensuring multi-resource mutations roll back completely when a downstream service fails mid-execution.
- Resource lifecycle management: Enforcing strict acquisition and release bounds on file descriptors, DB connections, memory buffers, and subprocesses.
- Idempotency boundaries: Guaranteeing that duplicate webhooks, retried HTTP requests, or out-of-order queue messages do not corrupt application state.
- Authorization consistency: Ensuring every endpoint verifies object-level authorization (IDOR protection) rather than relying on gateway-level session authentication alone.
Vulnerability Manifestations Across Language Runtimes
To understand how vibe coding anti-patterns permeate software stacks, consider how different programming languages express LLM-generated structural debt.
TypeScript: Syntactically Clean, Architecturally Flawed
Consider an API handler for processing user subscription upgrades generated during a vibe coding session in Node.js and TypeScript:
// Vibe-coded implementation: Syntactically clean, architecturally broken
export async function handleSubscriptionUpgrade(req: Request, res: Response) {
const { userId, planId, paymentMethodId } = req.body;
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const stripePayment = await stripe.paymentIntents.create({
amount: getPlanPrice(planId),
currency: "eur",
payment_method: paymentMethodId,
confirm: true,
});
if (stripePayment.status === "succeeded") {
await db.user.update({
where: { id: userId },
data: { plan: planId, status: "active" },
});
await db.auditLog.create({
data: { userId, action: "UPGRADE", planId },
});
return res.status(200).json({ success: true });
}
return res.status(400).json({ error: "Payment failed" });
}This function passes basic code reviews at a glance. It validates user existence, handles Stripe integration, checks payment status, updates the database, and records an audit log entry.
Under production conditions in a financial application in Frankfurt, this function exhibits severe failure modes:
- Missing input validation:
userId,planId, andpaymentMethodIdare unvalidated strings. An adversary can pass null, arbitrary types, or malicious payloads. - Race conditions (Time-of-Check to Time-of-Execution): Concurrent HTTP POST calls with the same
userIdexecute multiple Stripe payments simultaneously before any single database update completes. - Non-atomic operations: If the database write to
db.user.updatefails (such as connection pool exhaustion or lock timeout) afterstripe.paymentIntents.createsucceeds, the customer's credit card is charged in euros, but the application account state remains unchanged, and no audit record is created. - Missing payment idempotency key: The Stripe call omits an
idempotency_key, guaranteeing double-charging upon transient network drops between the server and the payment gateway. - Unverified HTTP context:
req.bodyis consumed directly without schema parsing via Zod or TypeBox, leaving the handler susceptible to object prototype injection or type-coercion bugs.
Go: Hidden Ignored Errors and Unbuffered Channel Deadlocks
In Go, LLMs frequently output code that appears idiomatically structured with standard error checking, yet bypasses edge-case handling or introduces concurrency deadlocks:
// Vibe-coded Go implementation: Hidden error swallowing and unbuffered channel deadlock
package main
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
)
type WorkerTask struct {
ID string `json:"id"`
Data string `json:"data"`
}
var taskQueue = make(chan WorkerTask) // Unbuffered channel!
func ProcessTaskHandler(w http.ResponseWriter, r *http.Request) {
var task WorkerTask
// LLM ignores decoding error details or body length bounds
_ = json.NewDecoder(r.Body).Decode(&task)
// Vibe-coded background dispatching via unbuffered channel
select {
case taskQueue <- task:
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"queued"}`))
default:
// If queue is full, HTTP 503 is returned, but body was already read into memory
http.Error(w, "Queue full", http.StatusServiceUnavailable)
}
}
func StartWorker(db *sql.DB) {
for task := range taskQueue {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// LLM generated query ignoring transaction bounds and error propagation
_, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'completed' WHERE id = $1", task.ID)
if err != nil {
// Silent logging without retry, DLQ insertion, or panic recovery
println("Failed to update task:", err.Error())
}
cancel()
}
}This Go code suffers from three critical architectural bugs:
- Unbuffered channel starvation:
make(chan WorkerTask)allocates an unbuffered channel. Under concurrent HTTP requests, if the single worker goroutine is executingdb.ExecContext, every incoming HTTP request immediately drops to thedefaultcase, failing legitimate traffic under minimal load spikes. - Body length exhaustion:
json.NewDecoder(r.Body)lackshttp.MaxBytesReader. An attacker can stream a 2 GB JSON payload intoProcessTaskHandler, blowing out process memory allocations. - Silent database write failures: When
db.ExecContextfails due to a transient database network partition, the error is printed to stdout without re-queuing the task or recording failure metrics, causing permanent state desynchronization between HTTP clients and storage.
Rust: Proliferation of Panic Escape Hatches and Lock Contention
In Rust, the compiler forces memory safety and explicit error handling. However, LLMs routinely bypass Rust's safety guarantees by peppering generated code with .unwrap(), .expect(), and improper async lock retention across .await points:
// Vibe-coded Rust implementation: Unhandled panics and Tokio async lock deadlock
use std::sync::Arc;
use tokio::sync::Mutex;
use axum::{extract::State, Json};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct TransferRequest {
pub from_account: u64,
pub to_account: u64,
pub amount: u64,
}
pub struct BankState {
pub db_connection_string: String,
pub cache: Mutex<std::collections::HashMap<u64, u64>>,
}
pub async fn transfer_funds(
State(state): State<Arc<BankState>>,
Json(payload): Json<TransferRequest>,
) -> &'static str {
// Vulnerability 1: Holding Tokio Mutex lock guard ACROSS an await boundary!
let mut cache_guard = state.cache.lock().await;
let sender_balance = cache_guard.get(&payload.from_account).copied().unwrap_or(0);
if sender_balance < payload.amount {
return "Insufficient funds";
}
// Artificial async I/O operation while holding cache_guard
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Vulnerability 2: Direct unwrap panics worker thread on DB failure
let mut db_client = tokio_postgres::connect(&state.db_connection_string, tokio_postgres::NoTls)
.await
.unwrap() // Panic if DB connection fails!
.0;
// Mutate cache while guard is still held
cache_guard.insert(payload.from_account, sender_balance - payload.amount);
"Transfer successful"
}The Rust compiler accepts this code, but runtime performance collapses under load:
- Tokio Async Lock Deadlock Across Await Points: Holding
cache_guardacrosstokio::time::sleep().awaitblocks all other worker tasks requiring access tostate.cachefor the entire 100 ms duration. If task pool execution queue depth increases, all async worker threads stall waiting for the single mutex guard. - Panic Cascades: Calling
.unwrap()ontokio_postgres::connecttriggers an unhandledpanic!if Postgres resets a TCP connection. While Axum catches task panics at the HTTP handler boundary, repeated connection failures exhaust thread pool worker capacity and drop process metrics.
The vibe coder accepted these implementations because during manual testing with single-threaded curl requests or local Postman scripts, sending a valid payload yielded expected responses. Syntactic correctness satisfied the sensory requirement, masking fatal underlying flaws.
Missing Boundary Conditions and Defensive Guardrails
LLM-generated code consistently targets the happy path, which forms the vast majority of public code repositories used as training sets. Edge cases, boundary failures, resource constraints, and malformed inputs represent a tiny fraction of typical code examples online. Consequently, generated code lacks defensive guardrails unless explicitly prompted for every permutation of failure.
1. Unvalidated Input, Type Coercion, and Object Injection
Vibe-coded projects routinely trust external payload structure. In languages with dynamic JSON parsing or weak static typing (such as JavaScript, Python, or PHP), the model generates direct property access on unvalidated objects.
Even in strongly typed languages like TypeScript, an LLM often casts external input using as assertions or trusts req.body without runtime schema enforcement.
// Flawed: Type assertion bypassing runtime check
interface UserInput {
age: number;
role: string;
}
app.post("/profile", (req, res) => {
// The LLM writes this type assertion, which disappears at runtime
const input = req.body as UserInput;
// If input.age is passed as a string "30" or an array, arithmetic and comparison logic break
if (input.age > 18) {
promoteUser(input.role);
}
});When malicious or malformed JSON payloads are supplied:
{
"age": [100],
"role": "admin"
}JavaScript array comparison semantics cause [100] > 18 to evaluate to true (due to implicit string coercion "100" > 18), successfully triggering the elevation of privileges logic.
Furthermore, dynamic object merges without key sanitization allow Prototype Pollution attacks:
// Flawed deep merge generated by LLM for user settings update
function updateSettings(userConfig, inputConfig) {
for (let key in inputConfig) {
if (typeof inputConfig[key] === 'object') {
updateSettings(userConfig[key], inputConfig[key]);
} else {
userConfig[key] = inputConfig[key];
}
}
return userConfig;
}If an attacker submits {"__proto__": {"admin": true}}, the recursive merge modifies Object.prototype, granting administrative permissions to every request in the Node.js process lifecycle.
2. Numerical Overflow, Precision Loss, and Fixed-Width Integer Boundaries
LLMs frequently write code that performs financial or resource calculations using standard IEEE 754 floating-point numbers or fixed-width integer types without handling overflow boundaries.
In JavaScript, Number.MAX_SAFE_INTEGER ($2^{53} - 1 = 9,007,199,254,740,991$) bounds exact integer representation. Vibe-coded database integrations handling 64-bit integer primary keys (such as Snowflake IDs or BigInt database columns) often deserialize JSON numeric IDs directly into JavaScript standard numbers, truncating lower order bits and causing cross-user data leaking.
// Flawed JavaScript JSON parsing of 64-bit BigInt IDs
const payload = '{"orderId": 900719925474099312}';
const parsed = JSON.parse(payload);
console.log(parsed.orderId); // Outputs: 900719925474099300 (Precision lost!)When fetching records using parsed.orderId, the query matches a different customer record entirely, exposing private user data across account boundaries.
In native languages like C, Rust, or Go, floating-point arithmetic for currency amounts introduces fractional precision loss:
$$\text{Calculation error: } 0.1 + 0.2 = 0.30000000000000004$$
Accumulating floating-point values across thousands of transactions creates accounting discrepancies and balance audit failures. Currency operations require exact integer amounts (such as cents or satoshis) or fixed-point decimal abstractions (decimal.Decimal in Python or big.Int in Go).
3. Missing Network Sockets, TCP Keep-Alives, and Timeout Limits
LLM-generated network interactions rarely include explicit socket timeouts or cancellation tokens. Standard HTTP clients in Python (requests), Node.js (fetch / axios), or Go (http.Client) default to infinite or long timeouts unless explicitly configured.
# Vibe-coded Python service integration
import requests
def fetch_exchange_rates():
# LLM writes simple requests.get without timeout argument
response = requests.get("https://api.internal.bank/v1/rates")
return response.json()If the remote host drops TCP packets or holds the HTTP connection open without sending bytes, the Python worker thread blocks indefinitely. Under modest traffic, all web server worker threads (such as Gunicorn or Uvicorn workers) become exhausted in a hung READ state, causing complete application outage.
Similarly, in Node.js, http.Agent defaults to maxSockets: Infinity in older versions or lacks active TCP keep-alive probe configuration. When connecting to upstream microservices behind load balancers that drop silent TCP connections, socket pools fill up with dead descriptors, hanging upstream request dispatches.
4. Resource Lifecycle Management and File Descriptor Exhaustion
LLM-generated file parsing and stream processing routines consistently omit cleanup logic in exception paths:
# Flawed stream processing generated by LLM
def process_log_files(file_paths):
records = []
for path in file_paths:
f = open(path, 'r') # File handle opened without context manager!
data = f.read()
if "CRITICAL" in data:
records.append(parse_critical(data)) # Exception here leaves 'f' open!
return recordsIf parse_critical raises an exception or if file_paths contains thousands of files, file handles remain open in memory until garbage collection runs. Operating systems enforce strict process limits on open file descriptors (ulimit -n, typically defaulting to 1024 on Linux distributions). Once exhausted, any subsequent socket creation, database connection, or file read fails with EMFILE: too many open files.
Refactored Defensive Guardrail Implementation
To eliminate these vulnerabilities, every input boundary must enforce strict runtime parsing, numerical safety limits, explicit resource limits, and transactional safety:
import { z } from "zod";
import { PrismaClient } from "@prisma/client";
import Stripe from "stripe";
const db = new PrismaClient();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2023-10-16" });
// 1. Enforce strict runtime payload schema with Zod
const UpgradePayloadSchema = z.object({
userId: z.string().uuid(),
planId: z.enum(["tier_basic", "tier_pro", "tier_enterprise"]),
paymentMethodId: z.string().startsWith("pm_"),
});
export async function handleSubscriptionUpgradeDefensive(req: Request): Promise<Response> {
// 2. Runtime Schema Validation
const parseResult = UpgradePayloadSchema.safeParse(await req.json());
if (!parseResult.success) {
return new Response(JSON.stringify({ error: "Invalid payload", details: parseResult.error.format() }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const { userId, planId, paymentMethodId } = parseResult.data;
const idempotencyKey = `sub_upgrade_${userId}_${planId}_${Date.now()}`;
try {
// 3. Database Transaction with Pessimistic Locking (SELECT FOR UPDATE)
return await db.$transaction(async (tx) => {
const user = await tx.user.findUnique({
where: { id: userId },
select: { id: true, status: true, plan: true },
});
if (!user) {
return new Response(JSON.stringify({ error: "User not found" }), { status: 404 });
}
if (user.plan === planId && user.status === "active") {
return new Response(JSON.stringify({ error: "Account already on requested plan" }), { status: 409 });
}
// 4. External Payment Call with Explicit Timeout and Idempotency Key
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8-second ceiling
const paymentIntent = await stripe.paymentIntents.create(
{
amount: getPlanPriceInCents(planId),
currency: "eur",
payment_method: paymentMethodId,
confirm: true,
off_session: true,
},
{
idempotencyKey,
}
).finally(() => clearTimeout(timeoutId));
if (paymentIntent.status !== "succeeded") {
return new Response(JSON.stringify({ error: "Payment authorization failed" }), { status: 402 });
}
// 5. Atomic state update within database transaction
await tx.user.update({
where: { id: userId },
data: { plan: planId, status: "active" },
});
await tx.auditLog.create({
data: { userId, action: "UPGRADE", planId, idempotencyKey },
});
return new Response(JSON.stringify({ success: true }), { status: 200 });
});
} catch (error) {
// Standard error log omitting sensitive payload details
console.error("Subscription upgrade transaction aborted:", { userId, error: (error as Error).message });
return new Response(JSON.stringify({ error: "Internal processing error" }), { status: 500 });
}
}Concurrency and State Mutability Disasters
AI coding tools exhibit a major architectural limitation in handling asynchronous execution and shared state mutability. Because code generation evaluates snippets linearly, models consistently output Read-Modify-Write (RMW) patterns that fail under concurrent execution.
1. Asynchronous Race Conditions (Lost Updates)
Consider an application managing account wallet balances. The vibe coder asks the LLM to write a function that deducts user credits when consuming an API endpoint.
// Flawed Read-Modify-Write pattern generated by LLM
async function deductUserBalance(userId: string, cost: number): Promise<boolean> {
const account = await db.account.findUnique({ where: { userId } });
if (account.balance < cost) {
return false; // Insufficient funds
}
// Artificial async gap or external service call
await logUsageMetric(userId, cost);
// Vulnerability: State was fetched above and modified locally.
// Concurrent calls during the async gap read the OLD balance!
const newBalance = account.balance - cost;
await db.account.update({
where: { userId },
data: { balance: newBalance },
});
return true;
}If a user triggers ten parallel requests simultaneously (such as sending rapid HTTP calls via a script from a client in Paris), all ten executions read the exact same initial balance $B = 100$. Each handler calculates $100 - 10 = 90$ and writes $90$ back to the database.
Instead of deducting $100$ total credits ($10 \times 10$), the account balance ends at $90$. The system suffers $90$ units of financial leakage due to a classic lost update race condition.
Request 1 (t=0): READ balance -> 100
Request 2 (t=1): READ balance -> 100
Request 3 (t=2): READ balance -> 100
Request 1 (t=3): WRITE 100 - 10 = 90 -> DB
Request 2 (t=4): WRITE 100 - 10 = 90 -> DB (Lost Update!)
Request 3 (t=5): WRITE 100 - 10 = 90 -> DB (Lost Update!)Atomic Database Mutations and Concurrency Control Mechanics
The fix requires executing balance modifications atomically at the database engine layer using atomic SQL expressions, optimistic locking version counters, or explicit row locking.
Option A: Atomic SQL Arithmetic
-- Atomic update eliminating Read-Modify-Write race condition
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2 AND balance >= $1;In Prisma / TypeScript:
async function deductUserBalanceAtomic(userId: string, cost: number): Promise<boolean> {
try {
// Atomic update guarded by balance constraint in DB engine
const updatedAccount = await db.account.updateMany({
where: {
userId,
balance: { gte: cost },
},
data: {
balance: { decrement: cost },
},
});
// If count === 0, balance was insufficient at execution time
return updatedAccount.count > 0;
} catch (error) {
throw new Error(`Failed to deduct balance atomically: ${(error as Error).message}`);
}
}Option B: Optimistic Locking with Version Attribution
Optimistic concurrency control assigns an incremental version column to records:
$$\text{State update condition: } \text{WHERE id} = \text{target_id AND version} = \text{expected_version}$$
If another thread updates the record concurrently, the version increments, causing the statement to update zero rows and triggering a retry loop:
async function deductUserBalanceOptimistic(userId: string, cost: number): Promise<boolean> {
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const account = await db.account.findUnique({ where: { userId } });
if (!account || account.balance < cost) return false;
const result = await db.account.updateMany({
where: {
userId,
version: account.version, // Guarded by version match
},
data: {
balance: account.balance - cost,
version: { increment: 1 },
},
});
if (result.count > 0) return true; // Successfully updated without collision
// Retrying on version collision...
}
throw new Error("Concurrency lock collision threshold exceeded");
}2. Memory Leak Patterns in Async Event Loops and Garbage Collectors
In long-running Node.js or Python backend processes, vibe-coded routines often register event handlers, global bus subscriptions, or interval timers inside request scope without cleanup.
Uncleaned Event Listeners
// Flawed: Registering event listener per HTTP request
app.get("/stream-events", (req, res) => {
const userId = req.query.userId;
// LLM writes this to connect request to a central event bus
globalEventEmitter.on("system-alert", (alert) => {
res.write(`data: ${JSON.stringify(alert)}\n\n`);
});
// Missing cleanup on client disconnect!
// Every request permanently appends a callback closure holding 'res' in memory.
});Each HTTP connection attaches a new function reference to globalEventEmitter. When clients disconnect, the closures remain referenced in memory inside the EventEmitter listener array, holding the HTTP response stream context intact. Within hours, heap usage climbs until the process crashes with FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory.
Circular References and V8 GC Heap Retention
V8 uses a Mark-and-Sweep garbage collection algorithm starting from Root references (global objects, active call stacks, DOM elements). When an LLM generates caching structures using plain JavaScript objects (const cache = {}) instead of WeakMap or bounded LRU caches, referenced items can never be garbage collected:
// Flawed in-memory cache generated by LLM
const userContextCache: Record<string, any> = {};
export async function getUserContext(req: Request) {
const token = req.headers.get("authorization");
if (!token) return null;
if (!userContextCache[token]) {
// Large context payload created and stored forever under unbounded key
userContextCache[token] = await fetchLargeUserContext(token);
}
return userContextCache[token];
}Because userContextCache is defined at module root scope, every unique authorization token string persists permanently in V8 Old Space memory. The garbage collector traverses the root reference graph and determines all cached user objects are reachable, preventing heap release.
Diagnostic Heap Snapshot Analysis
When debugging vibe-coded memory leaks using Chrome DevTools heap snapshots or Node.js memory profiling (node --inspect), heap allocation traces reveal distinct leak signature patterns:
+-----------------------------------------------------------------------------------+
| V8 HEAP PROFILE DIAGNOSTIC |
| |
| Object Constructor | Count | Shallow Size | Retained Size | Retention Path |
| -------------------- | --------- | ------------ | ------------- | ---------------- |
| (closure) | 142,500 | 8.5 MB | 412.0 MB | globalEvents.listeners|
| HTTPIncomingMessage | 142,500 | 18.2 MB | 380.4 MB | closure context |
| ServerResponse | 142,500 | 24.1 MB | 355.1 MB | socket.parser |
| Socket | 142,500 | 114.0 MB | 290.8 MB | handle_wrap |
+-----------------------------------------------------------------------------------+The diagnostic snapshot shows that 142,500 HTTP request contexts are retained in memory despite client disconnections, directly traced to the globalEvents listener array holding closure scopes open.
Phantom Package Hallucinations and Supply Chain Attack Surfaces
Vibe coding relies heavily on third-party libraries. When prompted to solve a complex engineering task (such as parsing a rare file format or interacting with an obscure API), an LLM may hallucinate package names that sound plausible based on standard naming conventions.
1. Dependency Hallucination Mechanics and Typosquatting Attack Vectors
Transformer language models break text into Byte-Pair Encoding (BPE) subword tokens. When generating import statements for specialized functionality, the model predicts high-probability subword combinations.
If an LLM suggests:
npm install express-jwt-permissions-validatorThe developer often copies and pastes the shell command directly without checking npmjs.com or PyPI. If express-jwt-permissions-validator does not exist on the public package registry, an attacker can register that exact package name and upload a malicious payload containing an automated postinstall binary execution hook.
{
"name": "express-jwt-permissions-validator",
"version": "1.0.0",
"scripts": {
"postinstall": "node ./scripts/exfiltrate-env.js"
}
}When the developer or CI/CD pipeline runs npm install, the package executes exfiltrate-env.js, extracting .env contents, AWS secret keys, database credentials, and SSH keys to an external command-and-control server.
+-------------------------------------------------------------------------+
| SUPPLY CHAIN HALLUCINATION ATTACK VECTOR |
| |
| 1. Developer Prompts LLM for Niche Feature |
| 2. LLM Hallucinates Plausible Package: `npm install lib-x-validator` |
| 3. Attacker Scrapes Prompt Leak Logs or Guesses Package Names |
| 4. Attacker Registers `lib-x-validator` on npm Registry with Malware |
| 5. Developer / CI Execution Runs `npm install` -> Triggering Malware |
| 6. Sensitive Environment Variables Exfiltrated to External Server |
+-------------------------------------------------------------------------+2. Dependency Tree Bloat and Unpinned Version Metrics
LLM code generation introduces unnecessary micro-dependencies. Instead of using native standard library functions (such as Node.js native crypto.randomUUID() or Python's math / pathlib), LLMs frequently import third-party packages for minor utility tasks.
Furthermore, generated package.json files default to loose version matching:
{
"dependencies": {
"bad-utility-pkg": "^2.1.0",
"another-lib": "*"
}
}The caret (^) permits automatic minor and patch updates, while * accepts any version release. Without lockfile discipline (package-lock.json, pnpm-lock.yaml, or poetry.lock) committed and enforced via npm ci in continuous integration, an upstream dependency compromise automatically infects production builds during routine redeployment.
Dependency Tree Metrics: Handcrafted vs. Vibe-Coded Project
Comparing dependency metrics between an architected system and a vibe-coded repository illustrates the expanded attack surface:
$$\text{Transitive Risk Ratio} = \frac{\text{Total Transitive Dependencies}}{\text{Direct Required Dependencies}}$$
| Dependency Metric | Architected System | Vibe-Coded System |
|---|---|---|
| Direct Dependencies | 12 | 48 |
| Transitive Dependencies | 145 | 1,820 |
| Install Size on Disk | 42 MB | 680 MB |
Unpinned Version Specs (^, *) |
0% | 85% |
| Known CVE Vulnerabilities | 0 | 14 |
| Postinstall Binary Hooks | 0 | 9 |
The Illusion of Test Coverage: Circular Validation and Mock Abuse
To demonstrate project quality, vibe coders frequently instruct LLMs to generate unit and integration test suites:
"Write comprehensive unit tests with 100% coverage for the auth module."
This practice creates a false sense of security through circular validation.
1. Circular Validation Bias and Missing Boundary Tests
When an LLM generates a unit test for code it previously produced, it evaluates the test implementation against the exact same underlying statistical distribution and flawed assumptions that created the implementation code. The generated test asserts what the code currently does, not what the software is required to do.
Consider a flaw in a tax calculation function where international VAT is calculated using local base currency without applying region-specific rates:
// Flawed implementation
export function calculateTotal(amount: number, taxRate: number): number {
// LLM forgot to add taxRate fraction conversion: taxRate passed as 20 for 20%
return amount + taxRate; // Bug: 100 + 20 = 120, but for amount=100 and taxRate=20 math matches 100 * 1.20 = 120!
}
// LLM-generated unit test
test("calculateTotal adds tax correctly", () => {
const result = calculateTotal(100, 20);
// Test asserts the exact buggy output!
expect(result).toBe(120);
});When amount is $50$ and taxRate is $20$, calculateTotal(50, 20) returns $70$ (instead of $60$), but the test suite passed initial execution during setup because the LLM generated test inputs ($100$ and $20$) where the flawed code output matched the expected output value by numerical coincidence.
Furthermore, LLM test generation systematically omits Boundary Value Analysis (BVA) test cases, failing to generate tests for:
- Empty arrays,
null,undefined, or zero-length buffer inputs. - Integer boundaries (
MAX_SAFE_INTEGER, negative values, zero). - UTF-8 string encoding edge cases (emoji surrogates, multi-byte strings, null byte injections).
- Network disruption timeouts and connection pool dropouts.
2. Over-Mocking, Isolation Traps, and Synthetic Green Pipelines
LLMs excel at synthesizing mock structures. When tasked with writing unit tests for handlers that touch databases, Redis caches, or external third-party REST APIs, the LLM heavily mocks network interfaces and database access drivers:
// Flawed test: Complete mock isolation hiding real bugs
test("getUserProfile returns user data", async () => {
const mockDb = {
user: {
findUnique: jest.fn().mockResolvedValue({ id: "123", name: "Alice", balance: 50 }),
},
};
const service = new UserService(mockDb as any);
const user = await service.getUserProfile("123");
expect(user.name).toBe("Alice");
expect(mockDb.user.findUnique).toHaveBeenCalledWith({ where: { id: "123" } });
});This test suite reports 100% code branch coverage in test runners (such as Vitest or Jest). However:
- It fails to test actual SQL syntax or Prisma query compatibility.
- It omits connection pool exhaustion testing under load.
- It fails to detect database migration schema drifts (such as
balancecolumn being renamed toavailable_balancein a migration file). - It ignores database column lock contention and transaction isolation boundaries.
The test suite validates that JavaScript code interacts with JavaScript mock objects. It yields zero information regarding whether the system functions reliably in production environments.
Ephemeral Integration Testing with Real Database Containers
Effective testing requires running assertions against real infrastructure instances (such as localized Docker containers or Testcontainers) with boundary failure cases:
import { GenericContainer, StartedTestContainer } from "testcontainers";
import { PrismaClient } from "@prisma/client";
import { execSync } from "child_process";
describe("UserService Integration Tests (Real Postgres)", () => {
let container: StartedTestContainer;
let prisma: PrismaClient;
beforeAll(async () => {
// 1. Spin up ephemeral real PostgreSQL container
container = await new GenericContainer("postgres:16-alpine")
.withEnvironment({ POSTGRES_DB: "test_db", POSTGRES_PASSWORD: "secret_password" })
.withExposedPorts(5432)
.start();
const mappedPort = container.getMappedPort(5432);
const dbUrl = `postgresql://postgres:secret_password@localhost:${mappedPort}/test_db?schema=public`;
process.env.DATABASE_URL = dbUrl;
execSync("npx prisma migrate deploy");
prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
}, 30000);
afterAll(async () => {
await prisma.$disconnect();
await container.stop();
});
test("Concurrent balance deductions handle race conditions without overdrawing", async () => {
// 2. Seed initial state
const user = await prisma.user.create({
data: { id: "user_concurrency_test", balance: 100 },
});
// 3. Execute 10 parallel atomic deduction requests
const attempts = Array.from({ length: 10 }).map(() =>
deductUserBalanceAtomic("user_concurrency_test", 30)
);
const results = await Promise.all(attempts);
// 4. Verify exact invariant: Only 3 deductions of 30 can succeed from 100 total balance
const successCount = results.filter((res) => res === true).length;
expect(successCount).toBe(3);
const finalAccount = await prisma.user.findUnique({ where: { id: "user_concurrency_test" } });
expect(finalAccount?.balance).toBe(10); // Exactly 10 remaining balance
});
});Remediation Framework: Replacing Vibes with Engineering Discipline
Transitioning a codebase away from vibe coding vulnerabilities requires establishing rigid automated feedback mechanisms and manual architectural enforcement gates. You cannot eliminate AI code generation tools entirely; rather, you must treat all LLM-synthesized code as untrusted input from an unvetted third-party contributor.
+-------------------------------------------------------------------------+
| SECURE DEVELOPMENT PIPELINE |
| |
| [LLM Output / Developer PR] |
| | |
| v |
| [Pre-Commit / Pre-Push Local Hooks] |
| - ESLint / Semgrep / Clippy / Pyright Strict Analysis |
| - Lockfile Integrity & Dependency Verification |
| | |
| v |
| [Automated CI Security Pipeline] |
| - Dependency Vulnerability & Hallucination Scanning (Audit Hooks) |
| - Real Containerized Integration & Concurrency Stress Suites |
| | |
| v |
| [Mandatory Human Architectural Review] |
| - Boundary Verification, Data Model Invariants & Threat Audit |
| | |
| v |
| [Production Deployment Gate] |
+-------------------------------------------------------------------------+1. Automated Static Analysis and AST Linting Gateways
Configure compiler options and static analysis engines to enforce strict type definitions and disallow dynamic escapes.
TypeScript Strict Configuration (tsconfig.json)
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true
}
}Setting "noUncheckedIndexedAccess": true forces the developer (and the LLM) to handle undefined checks explicitly when accessing arrays or index signatures, neutralizing hundreds of unexpected runtime exceptions.
Custom AST ESLint Rules for Vibe-Coding Anti-Patterns
Create custom AST linter plugins to ban unvalidated input parsing and type assertion escapes. Below is a custom ESLint plugin rule (no-unparsed-express-body.js) targeting req.body access:
// ESLint custom AST rule: Block direct req.body access without schema validation
module.exports = {
meta: {
type: "problem",
docs: {
description: "Disallow direct access to req.body properties without Zod validation",
},
schema: [],
},
create(context) {
return {
MemberExpression(node) {
// Detect req.body.foo access
if (
node.object.type === "MemberExpression" &&
node.object.object.name === "req" &&
node.object.property.name === "body"
) {
// Check if parent scope includes safeParse or parse call
const sourceCode = context.getSourceCode();
const ancestorText = sourceCode.getText(context.getScope().block);
if (!ancestorText.includes(".safeParse") && !ancestorText.includes(".parse")) {
context.report({
node,
message: "Direct property access on unvalidated req.body is forbidden. Parse payload using Zod schema first.",
});
}
}
},
};
},
};Custom Semgrep Security Scanning Rules
Deploy SAST engines like Semgrep in continuous integration pipelines to flag unvalidated input handlers and missing database transactions.
Create a project rule file .semgrep/security-rules.yaml:
rules:
- id: detect-unparsed-express-body
languages: [typescript, javascript]
severity: ERROR
message: "Express route handler accesses req.body without Zod or TypeBox runtime validation."
patterns:
- pattern-inside: |
app.$METHOD($PATH, (req, res) => { ... })
- pattern: req.body.$PROPERTY
- pattern-not-inside: |
$SCHEMA.parse(req.body)
- pattern-not-inside: |
$SCHEMA.safeParse(req.body)
- id: detect-missing-stripe-idempotency
languages: [typescript, javascript]
severity: WARNING
message: "Stripe API execution missing explicit idempotencyKey parameter."
patterns:
- pattern: stripe.paymentIntents.create($PAYLOAD)
- pattern-not: stripe.paymentIntents.create($PAYLOAD, { idempotencyKey: $KEY })
- id: detect-rust-unwrap-in-async
languages: [rust]
severity: ERROR
message: "Use of .unwrap() inside async function can panic worker thread."
patterns:
- pattern-inside: |
async fn $NAME(...) -> $RET { ... }
- pattern: $EXPR.unwrap()
- id: detect-go-ignored-exec-error
languages: [go]
severity: ERROR
message: "Ignored error return value in database SQL execution."
patterns:
- pattern: _ , _ = $DB.ExecContext(...)
- pattern: _ = $DB.Exec(...)2. Dependency Audit and Package Verification Protocol
To prevent hallucinated package execution and supply chain compromises:
- Disable script execution during installation by default:
Configure local development environments and CI pipelines using npm configuration:
npm config set ignore-scripts true - Enforce Lockfile Strictness:
In continuous integration workflows, forbid package tree resolution updates:
npm ci --ignore-scripts - Automated Registry Existence Lookup Script:
Before executing
npm install <package>, execute a registry lookup script to verify package publication history, download metrics, and maintainer details:#!/usr/bin/env bash set -euo pipefail PKG_NAME="$1" REGISTRY_URL="https://registry.npmjs.org/${PKG_NAME}" HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${REGISTRY_URL}") if [ "$HTTP_STATUS" -eq 404 ]; then echo "CRITICAL: Package '${PKG_NAME}' does not exist on npm registry!" echo "Possible LLM hallucination detected. DO NOT INSTALL." exit 1 fi DOWNLOADS=$(curl -s "https://api.npmjs.org/downloads/point/last-week/${PKG_NAME}" | jq -r '.downloads // 0') if [ "$DOWNLOADS" -lt 100 ]; then echo "WARNING: Package '${PKG_NAME}' has fewer than 100 weekly downloads (${DOWNLOADS})." echo "Inspect package repository manually before proceeding." else echo "Package '${PKG_NAME}' verified on registry (${DOWNLOADS} weekly downloads)." fi
3. Human Code Inspection Discipline and PR Review Checklist
Establish rigid code review rules for pull requests containing AI-assisted contributions:
+-----------------------------------------------------------------------------------+
| AI-ASSISTED CODE REVIEW CHECKLIST |
| |
| [ ] 1. INPUT BOUNDARIES : Are all HTTP/RPC inputs parsed with runtime schemas?|
| [ ] 2. CONCURRENCY SAFETY : Do DB updates use atomic operations or locks? |
| [ ] 3. IDEMPOTENCY KEYS : Are external API calls (Stripe/SendGrid) idempotent?|
| [ ] 4. TIMEOUT CEILINGS : Are all network requests bound by explicit timeouts?|
| [ ] 5. TYPE CAST ESCAPES : Are `as any`, `as unknown`, or `.unwrap()` banned? |
| [ ] 6. RESOURCE CLEANUP : Are sockets, files, and listeners explicitly closed?|
| [ ] 7. AUTHORIZATION CHECKS : Does every endpoint verify object ownership (BOLA)? |
| [ ] 8. INTEGER BOUNDARIES : Are BigInt IDs parsed as strings to avoid truncation?|
| [ ] 9. REAL INTEGRATION TEST: Do tests run against real DB instances in Docker? |
| [ ] 10. DEPENDENCY AUDIT : Are all new packages verified on public registries? |
+-----------------------------------------------------------------------------------+- Rule 1: Trace execution graphs manually. Never approve a PR based solely on passing unit tests or working visual previews. Read every line of execution path from HTTP ingress to database persistence.
- Rule 2: Reject type assertions and unwrap calls. Banish
as any,as UnknownType, non-null assertions (!), and Rust.unwrap()from production code paths. Force explicit runtime narrowings (if (val !== undefined)). - Rule 3: Require explicit boundary validation. Every API handler, queue consumer, or command-line parser must feature runtime schema verification (such as Zod, Pydantic, or Valibot) at entry point boundaries.
- Rule 4: Verify atomic concurrency patterns. Audit all database mutations. Any code reading a database record, modifying values in memory, and writing back to storage must be refactored to use atomic database operations or explicit row-level locks.
- Rule 5: Enforce threat modeling for auth pathways. Every endpoint exposing object fetching or modification must undergo manual verification for Object Level Authorization (BOLA/IDOR) vulnerabilities.
By replacing qualitative sensory feedback loops with deterministic static analysis, real containerized integration tests, strict lockfile discipline, custom AST linter rules, and rigorous threat modeling, engineering teams can harness the velocity of generative models without shipping fragile, exploitable systems to production.