The Security Audit Checklist for AI-Generated PHP: 7 Vulnerability Patterns in Every Vibe-Coded Codebase

#AI generated PHP security
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

If you have shipped a PHP project with significant AI-generated code in the last two years, there is a reasonable chance it contains at least three of the patterns described here. That is not a guess — it is what we find on nearly every codebase audit we run at Wolf-Tech for teams that accelerated delivery using AI assistants. The vulnerabilities are not random. They are predictable, because they emerge from predictable gaps in how large language models generate PHP.

This post works through the seven patterns, explains the underlying reason LLMs produce them, and gives you the static analysis rules — GrumPHP hooks, PHPStan extensions, Rector rules — that intercept them in CI before they reach production.

Why AI-Generated PHP Has a Distinct Security Signature

Language models generate code by predicting what looks correct given everything they have seen in training data. Security vulnerabilities are underrepresented in training corpora for two reasons. First, vulnerable code rarely appears with a label that says "this is vulnerable." It appears alongside everything else in the repository. Second, the most common examples of any pattern — a database query, a file read, a redirect — are written quickly, without the hardening layers that a seasoned developer would add in a production context.

The result is code that passes a surface read and fails a security audit. The seven patterns below are the ones that appear in every codebase, in roughly this order of frequency.

Pattern 1: Raw SQL Strings Adjacent to ->execute() Calls

The most common finding is SQL injection introduced by string interpolation or concatenation into a query that is then passed directly to a PDO or Doctrine execute() call. The LLM knows how to write parameterized queries — it will produce them when explicitly prompted. When not prompted, it takes the shorter path that matches the dominant pattern in its training data: interpolating the variable directly.

// What AI frequently generates
$query = "SELECT * FROM users WHERE email = '$email' AND status = 'active'";
$stmt = $pdo->query($query);

// What it should generate
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND status = 'active'");
$stmt->execute([$email]);

The PHPStan extension to catch this is phpstan/phpstan-strict-rules combined with a custom rule that flags string interpolation inside any expression passed to query(), exec(), or execute(). If you are using Doctrine, the Doctrine extension for PHPStan (phpstan/phpstan-doctrine) will flag raw DQL construction similarly.

In GrumPHP, add a phpstan task at level 6 or above with the strict rules extension enabled. This catches the pattern at commit time rather than at review time.

Pattern 2: Missing CSRF Tokens on Custom Form Submissions

Symfony's Security component handles CSRF correctly when you use the form builder. AI-generated code frequently bypasses the form builder for simple forms — contact forms, preference toggles, inline edit controls — and generates a plain HTML form with a POST endpoint that validates nothing but the field values.

// AI-generated controller — no CSRF check
public function updatePreferences(Request $request): Response
{
    $user->setNewsletterEnabled($request->request->getBoolean('newsletter'));
    $this->entityManager->flush();
    return new JsonResponse(['success' => true]);
}

The fix is one line with Symfony's CSRF manager, but the AI does not add it because CSRF tokens are not part of the basic POST-endpoint pattern it is matching against. In Rector, you can write a custom rule that flags any $request->request->get() or $request->request->getBoolean() call inside a controller action that lacks a corresponding $this->isCsrfTokenValid() call. It will not catch every case, but it will surface the most exposed endpoints immediately.

Pattern 3: User-Controlled Paths Passed to File Operations

File path traversal vulnerabilities appear when AI-generated code accepts a filename or path segment from user input and passes it — sometimes with a realpath call, sometimes without — to file_get_contents, readfile, unlink, or similar functions. The realpath call provides false confidence: it resolves the path, but does not enforce that it stays within the intended directory.

// AI-generated — traversal possible even with realpath
$path = realpath('/var/app/uploads/' . $request->query->get('file'));
return new BinaryFileResponse($path);

An attacker can supply ../../../etc/passwd as the file parameter. The correct pattern is to resolve the path and then assert that it begins with the allowed prefix.

PHPStan with the thecodingmachine/phpstan-safe-rule package will flag unsafe use of filesystem functions. Adding a custom PHPStan rule that requires a str_starts_with($path, $allowedBasePath) guard before any file response is returned covers this systematically across the codebase. For Wolf-Tech audits, this rule alone has surfaced path traversal risks in four out of five file-serving features reviewed.

Pattern 4: Secrets Hardcoded in .env.example That Shipped to Git

This one is subtle. The AI-generated .env.example frequently contains real-looking placeholder values — JWT secrets that are actually 32-character random strings the model generated, API keys formatted exactly like real keys, database passwords with believable entropy. Some teams copy .env.example to .env in CI and never change the values. Others use the example values as their staging credentials.

The problem compounds when .env.example is committed with values that match what the AI also placed in config/packages/security.yaml as a fallback. The "placeholder" is now a real credential embedded in version history.

GrumPHP's git_blacklist task with patterns covering JWT secret formats, AWS key patterns (AKIA[A-Z0-9]{16}), and high-entropy strings ([A-Za-z0-9+/]{32,}) catches most of these before they enter the repository. Supplement this with trufflesecurity/trufflehog as a CI step on every pull request. It is fast, free for public repositories, and its false-positive rate on PHP codebases is low.

Pattern 5: Over-Privileged JWT Claims With No Expiry Validation

AI-generated JWT authentication code tends to implement the encoding and decoding correctly and then skip the validation step that actually enforces constraints. The decoded token is trusted if the signature is valid. Expiry (exp) is not checked. Role claims are read directly from the token without verification against the database.

// AI-generated JWT handler — signature checked, claims not validated
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
$userId = $decoded->sub;
$role = $decoded->role; // taken at face value, never cross-referenced

An attacker who obtains any valid token — including an expired one from a revoked session — can modify the role claim, re-sign with the same secret if it was exposed (see Pattern 4), and escalate privileges. The fix involves validating exp, iat, and cross-referencing role claims against the database on actions that require elevated access.

PHPStan cannot catch missing business logic reliably, but a custom architecture test using PHPUnit's assertNotEmpty on a service interface contract works: define a JwtValidatorInterface that requires validateExpiry() and validateClaims() methods, and assert in CI that any class implementing JWT decode also implements this interface.

Pattern 6: Open Redirects Introduced by AI-Appended Query Parameters

Redirect-after-login is a standard pattern. AI-generated implementations frequently append the destination URL as a raw query parameter and redirect to it without validation.

// AI-generated — open redirect
$returnUrl = $request->query->get('return_url', '/dashboard');
return new RedirectResponse($returnUrl);

A phishing attack passes return_url=https://evil.example.com in the login link. The user authenticates on your domain and lands on the attacker's page, still believing they are in your application.

Symfony's UrlHelper does not solve this. The fix is asserting that the redirect target is a relative path or matches an allowlist of trusted domains. A custom PHPStan rule that flags new RedirectResponse($variable) where $variable originates from $request->query without a preceding validation call surfaces every instance across the codebase in a single run.

Pattern 7: Symfony Firewall Bypasses From Misconfigured access_control Ordering

Symfony's access_control rules are evaluated top-to-bottom and short-circuit on the first match. AI-generated security.yaml frequently places broad rules before specific ones, because the model is filling in a template in the order that reads most naturally in prose, not in the order that Symfony evaluates rules.

# AI-generated — admin panel is accessible without authentication
access_control:
    - { path: ^/api, roles: PUBLIC_ACCESS }
    - { path: ^/api/admin, roles: ROLE_ADMIN }

The /api/admin rule is never reached because /api matches first and grants public access. The correct ordering places the most specific paths first.

PHPStan cannot lint YAML semantics, but a Rector rule operating on the parsed security.yaml AST can enforce ordering constraints. Alternatively, an architecture test that loads the actual Symfony security configuration and asserts that any path containing admin in the pattern has a more restrictive role than any parent path catches this reliably in CI.

Putting It Together: The CI Pipeline That Catches All Seven

A practical CI gate that intercepts these patterns before merge involves four layers:

GrumPHP runs on commit: phpstan at level 6 with strict rules, git_blacklist with secret patterns, and composer validate. This takes under ten seconds and stops the obvious patterns before they enter the repository.

PHPStan runs in CI on every pull request with custom rules for the patterns above. The custom rules directory can live in tools/phpstan/src/ and be registered via phpstan.neon. Aim for zero ignored errors — a baseline file that grows over time is technical debt that silently absorbs real findings.

Rector runs in dry-run mode in CI and produces a diff. If the diff is non-empty, the build fails. This surfaces refactoring opportunities without blocking on them if the team needs to schedule them separately from the PR.

TruffleHog runs as a GitHub Actions step on every PR against the diff only, not the full history, for speed.

For teams running these tools for the first time on an AI-assisted codebase, the first PHPStan run at level 6 typically produces between 200 and 600 errors. That number is not a judgment — it reflects the gap between training-data-level PHP and production-grade PHP. Triaging through it systematically is exactly what a code audit produces: a prioritized list of findings with severity ratings and a remediation order that limits regression risk.

Starting Point

If you have a PHP codebase with significant AI-generated content and have not run a structured security audit, the seven patterns above are a reasonable first checklist. Each one has a detectable signature, a mechanical fix, and a CI rule that prevents recurrence.

The teams that handle this well treat it as a one-time triage pass followed by a CI gate that makes the pattern structurally impossible going forward. It typically takes a few days to instrument correctly and a sprint to remediate the backlog.

If you want an external eye on the findings before you prioritize — or if the audit surfaces something your team is not sure how to approach — reach out at hello@wolf-tech.io or visit wolf-tech.io. We have done this audit enough times that the common patterns are fast to triage and the edge cases are what actually take time.