Symfony Security Audit — Skill for AI Coding Agents

Transform your AI coding agent into a Symfony security auditor for thorough codebase evaluations.

Version: 1.0.0 (April 2026) Target: Symfony 6.x / 7.x applications

Some examples use #[MapQueryParameter] (Symfony 6.3+). On 6.0–6.2, replace with filter_var($request->query->get('id'), FILTER_VALIDATE_INT) and reject non-int values explicitly.

This skill turns your AI coding agent into a Symfony security auditor. It covers 10 recurring antipatterns and 4 deep-dive categories, with grep-based scan plans and a strict report format. Read-only by design — the agent reports, a human decides what to fix.

Sources:


When to use

  • You want a security pass on a Symfony 6/7 project before merging, before release, or as a periodic health check.
  • You are starting a new Symfony project and want guardrails before writing the first controller.
  • A code review surfaced a suspected security issue and you want a structured sweep of related code paths.

Do NOT use for: non-Symfony codebases, generic security questions, runtime / blackbox pentesting.


Modes

Mode pre — new or early-phase project

Use when the project has few or no controllers. No deep scan needed.

Steps:

  1. Confirm Symfony 6 / 7 via composer.json (symfony/framework-bundle version).
  2. Verify baseline configs are present (or flag as TODO). See Configuration baseline below.
  3. Print the 10 patterns as a guardrails checklist for the team to build against.
  4. Offer to add docs/security-checklist.md to the repo (only if the user agrees).

Keep output ≤ 25 lines, in the user's language, telegraphic.

Mode existing — established codebase

Use when the project has a real controller/entity surface. Run the full scan plan below.

If your agent runtime supports isolated sub-agents or background tasks (Claude Code Agent, Cursor background agents, etc.), delegate the scan to one so the main conversation context stays clean. Otherwise, run it inline.

Return the report, then surface the top 3 Critical / High findings by impact. Wait for the user to pick which to fix — do not auto-fix, do not open PRs.


Configuration baseline (both modes start here)

Verify these five controls. Any missing → flag as a baseline finding.

ControlCheckExpected
login_throttlingconfig/packages/security.yamlPresent under firewalls.main.login_throttling with max_attempts and interval.
Security headers bundlecomposer.jsonnelmio/security-bundle installed (or equivalent CSP / HSTS / clickjacking / nosniff config).
Session cookie flagsconfig/packages/framework.yamlsession.cookie_httponly: true, cookie_secure: true, cookie_samesite: lax.
Trusted hostsconfig/packages/framework.yaml or envkernel.trusted_hosts set (not empty).
Remember-me server-side revocationconfig/packages/security.yamlIf remember_me is used: token_provider.doctrine: true + rememberme_token table.

The 10 patterns

#PatternSignals to look forOne-line remediation
1SQL / DQL injectionexecuteQuery(".*{ interpolating variables; sort[ without a whitelist in controllers / EasyAdmin; DQL string concatenation.Typed placeholders (:id + ParameterType::INTEGER); whitelist sort[] against ClassMetadata::getFieldNames() in an EventSubscriber.
2Command injectionexec(, shell_exec(, system(, passthru(, backticks with interpolated variables.Symfony\Component\Process\Process with arguments as an array; for FS use native PHP (mkdir, chmod).
3Missing rate limitingNo login_throttling in security.yaml; no framework.rate_limiter; signup / password-reset / API endpoints with no consumer.security.firewalls.main.login_throttling + framework.rate_limiter with sliding_window or token_bucket; consume by IP in controllers.
4Secrets stored in plaintextColumns such as apiKey, token, secret queried via findOneBy(['apiKey' => $raw]) without hash().For high-entropy machine-generated tokens (API keys, session tokens): store only hash('sha256', $token); show the plain value exactly once at creation time; look up by the hash. For user passwords (low entropy): never sha256 — use UserPasswordHasherInterface (bcrypt / argon2id) via security.password_hashers.
5Security headers + cookiesnelmio/security-bundle missing; no CSP / HSTS / clickjacking / nosniff configured; session without cookie_httponly / cookie_secure / cookie_samesite.Install nelmio/security-bundle with CSP including frame-ancestors 'none' (modern) or X-Frame-Options: DENY (legacy UA fallback); HSTS (max_age: 31536000), nosniff, referrer-policy. Session cookies httponly + secure + samesite: lax.
6Unsigned webhooksPOST /webhook/... endpoints that don't call hash_equals, don't validate a DTO with Validator, and parse the body via $request->request->all().hash_equals(hash_hmac('sha256', $raw, $secret), $sig) over raw $request->getContent(); deserialize to a DTO and validate with Assert before processing.
7XSS|raw in Twig over user- or LLM-generated content; values reflected into <script> without |json_encode; .php / .phtml scripts under public/ interpolating $_GET with no escape.Default Twig escape; for limited HTML use |sanitize_html (html_sanitizer); |json_encode for values inside <script>. In public/*.php / *.phtml: whitelist regex + htmlspecialchars with ENT_QUOTES | ENT_SUBSTITUTE.
8Missing param whitelistVariable-keyed reads from the request: $req->get($k), $req->query->get($k), $req->request->get($k), $req->attributes->get($k) where $k is user-controlled. Filter / session setters accepting arbitrary fields; no const ALLOWED_*. Note: Request::get() is deprecated in 6+; prefer the specific bag, but the signal is identical.const ALLOWED_KEYS = [...] + match($key) { ... } with per-key validation; reject unknown keys with BadRequestHttpException.
9Path traversalFS operations taking user input without realpath(); regex that only filters .. or /.realpath($base . '/' . $input) + str_starts_with($resolved, $base . '/'); throw NotFoundHttpException if the resolved path leaves the base directory.
10Weak audit loggingLog::debug with full headers / body; no listener for SwitchUserEvent; permission changes or admin deletes with no trail.An AuditLogger service that redacts ['password','token','apikey','secret','authorization','cookie'] via array_walk_recursive; listeners for switch_user, login fail, admin mutations with {actor, target, ip, timestamp}.

The 4 deep dives

These are the most frequent and costly issues in real Symfony projects. Always review them.

Multi-tenant / IDOR — dominant antipattern

  • Symptom: controllers call $repo->find($request->get('id')) without checking that the entity belongs to the current user's tenant. Roles and listing filters do NOT substitute for an ownership check.
  • Grep: ->find\(, findOneBy\(\['id' inside src/**/Controller/**. For each match, verify the controller / service calls an assertXxxAccess helper / Voter / tenant filter in the same flow.
  • Remediation: a helper on BaseController that takes int and returns the entity if the user owns it, otherwise throws AccessDeniedHttpException. Controllers pass a typed ID via filter_var + FILTER_VALIDATE_INT or #[MapQueryParameter] int.

Example:

abstract class BaseController extends AbstractController {
    protected function assertCompanyAccess(int $id): Company {
        if ($this->isGranted('ROLE_SUPER_ADMIN')) {
            return $this->companyRepo->find($id) ?? throw new NotFoundHttpException();
        }
        $owned = $this->companyRepo->findOneBy([
            'id'     => $id,
            'tenant' => $this->getUser()->getAllowedTenantIds(),
        ]);
        return $owned ?? throw new AccessDeniedHttpException();
    }
}

public function metrics(#[MapQueryParameter] int $companyId): JsonResponse {
    $company = $this->assertCompanyAccess($companyId);
    return $this->json($this->metrics->build($company));
}

SSRF — user-supplied URLs

  • Symptom: the project lets a user enter a URL (adapter config, webhook destination, API base) and consumes it with HttpClient without validating the destination.
  • Grep: HttpClientInterface, HttpClient::create, ->request\( with a URL originating from a Request or a user-editable entity field.
  • Remediation: an ApiUrlValidator that rejects (a) hostnames in a blocklist (localhost, 169.254.169.254, metadata.google.internal, metadata.amazonaws.com, fd00:ec2::254), (b) IP literals in private / reserved ranges via FILTER_VALIDATE_IP | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. Note: DNS rebinding exists; where possible, resolve the IP up-front and pass it to HttpClient via resolve to pin it between validation and request.

Example:

final class ApiUrlValidator {
    public function __construct(private readonly array $blockedHostnames = []) {}

    public function isValid(?string $url): bool {
        if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) return false;
        $parts = parse_url($url);
        if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)) return false;
        $host = strtolower($parts['host'] ?? '');
        if ($host === '') return false;
        if (in_array($host, $this->blockedHostnames, true)) return false;
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
            ) !== false;
        }
        return true;
    }
}

EasyAdmin — mandatory overrides

  • Symptom: a CRUD controller extends AbstractCrudController without overriding detail / edit / delete; disableActions: [] enables deletion with no ownership check.
  • Grep: extends AbstractCrudController in src/**/Controller/**/*CrudController.php. Read each one: look for overrides of detail, edit, delete and a call to assertOwned or isGranted('VIEW', $entity).
  • Remediation: a base HardenedCrudController that overrides all three actions with assertOwned(AdminContext) (SUPER_ADMIN bypass; everyone else checks $entity->getTenant()->getId() ∈ user->getAllowedTenantIds()). Add an EventSubscriber validating the sort[] query param against ClassMetadata::getFieldNames() + direction ∈ {ASC, DESC}.

Example:

abstract class HardenedCrudController extends AbstractCrudController {

    public function createIndexQueryBuilder(SearchDto $s, EntityDto $e, FieldCollection $f, FilterCollection $fi): QueryBuilder {
        $qb = parent::createIndexQueryBuilder($s, $e, $f, $fi);
        if (!$this->isGranted('ROLE_SUPER_ADMIN')) {
            $qb->andWhere('entity.tenant IN (:tenants)')
               ->setParameter('tenants', $this->getUser()->getAllowedTenantIds());
        }
        return $qb;
    }

    public function detail(AdminContext $c): KeyValueStore|Response { $this->assertOwned($c); return parent::detail($c); }
    public function edit(AdminContext $c):   KeyValueStore|Response { $this->assertOwned($c); return parent::edit($c); }
    public function delete(AdminContext $c): KeyValueStore|Response { $this->assertOwned($c); return parent::delete($c); }

    private function assertOwned(AdminContext $c): void {
        if ($this->isGranted('ROLE_SUPER_ADMIN')) return;
        $entity  = $c->getEntity()->getInstance();
        $allowed = $this->getUser()->getAllowedTenantIds();
        if (!$entity || !in_array($entity->getTenant()?->getId(), $allowed, true)) {
            throw $this->createAccessDeniedException();
        }
    }
}

Auth flows — signup, verification, session

  • Symptom (common): GET /signup/finalize?hash=abc validates the hash; POST /signup/finalize doesn't revalidate it and accepts creating an account with any POSTed email. Or: remember-me without token_provider: doctrine → a cookie stolen via XSS survives logout.
  • Grep: controllers named signup, register, finalize, reset — check token revalidation at every step. remember_me in security.yaml — confirm token_provider.doctrine: true. Signup — confirm NotCompromisedPassword and PasswordStrength.
  • Remediation: in multi-step flows, each POST re-reads the temp record by (hash, email) and rejects 403 on mismatch. Remember-me with the doctrine provider + rememberme_token table; logout invalidates. Passwords: Assert\NotCompromisedPassword(skipOnError: true) + Assert\PasswordStrength(minScore: STRENGTH_STRONG) + Assert\Length(min: 12).

Example DTO:

final class SignupDto {
    #[Assert\Email(mode: 'strict')]
    #[Assert\NotBlank]
    public string $email = '';

    #[Assert\NotBlank]
    #[Assert\Length(min: 12)]
    #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_STRONG)]
    #[Assert\NotCompromisedPassword(skipOnError: true)]
    public string $password = '';

    #[Assert\NotBlank]
    public string $hash = '';  // revalidated against the temp record
}

Scan plan

Run the searches in parallel (one Grep / Glob per signal). Read only files with matches — never the whole tree.

Block A — Injection (patterns 1, 2, 7)

  • Grep "executeQuery\(.*\{|executeStatement\(.*\{" — SQL concat.
  • Grep "exec\(|shell_exec\(|system\(|passthru\(" outside vendor/.
  • Grep "\|raw" in templates/**/*.twig. For each match, classify: (a) |raw applied to a string constant / controller-built HTML → low risk; (b) |raw applied to a variable that ultimately traces to user input, request data, DB content of user origin, or LLM output → finding. Only (b) is reported; note the classification in the finding so it can be audited.
  • Glob "public/**/*.php" and public/**/*.phtml — read each; look for $_GET / $_POST without htmlspecialchars / validation.
  • Grep "sort\[" --glob "src/**/Controller/**" — EasyAdmin or paginator sort without whitelist.

Block B — Access control / IDOR (deep dive 1 + pattern 8)

  • Grep "->find\(|->findOneBy\(\['id'" --glob "src/**/Controller/**" — each match is a lookup; open the controller and verify an ownership check in the same method (or a helper called before).
  • Glob "src/**/Controller/**/*CrudController.php" — EasyAdmin; read each and verify overrides of detail / edit / delete.
  • Read config/packages/security.yaml — list access_control and firewalls; flag admin endpoints without a role.
  • Grep "\$session->set\(" followed by a variable argument — potential session poisoning.

Block C — Auth / session (patterns 3, 4 + deep dive 4)

  • Read config/packages/security.yaml — look for login_throttling, remember_me.token_provider.doctrine.
  • Read config/packages/framework.yamlcookie_httponly, cookie_secure, cookie_samesite, trusted_hosts, rate_limiter.
  • Grep "signup|finalize|reset" --glob "src/**/Controller/**" — record hash revalidation in POSTs.
  • Grep "NotCompromisedPassword|PasswordStrength" --glob "src/**".
  • Grep "findOneBy\(\['apiKey'|findOneBy\(\['token'" --glob "src/**" — plaintext credentials.

Block D — SSRF / webhooks (pattern 6 + deep dive 2)

  • Grep "HttpClientInterface|HttpClient::create|->request\(" --glob "src/**" — HTTP call sites; check if the URL comes from the user.
  • Grep "FILTER_FLAG_NO_PRIV_RANGE|blocked_hostnames|169\.254" — SSRF validator present or not.
  • Grep "webhook" --glob "src/**/Controller/**" — each webhook must call hash_equals.

Block E — Observability / headers / secrets (patterns 5, 10)

  • Read composer.jsonnelmio/security-bundle present?
  • Grep "Log::(debug|info).*header|json_encode.*\$request->headers" — possible log leaks.
  • Read .gitignore.env.local, .env.*.local, .env.*.php excluded?
  • Grep "SwitchUserListener|SwitchUserEvent" — is switch_user audited?
  • Grep "exec\(\"mkdir|exec\(\"chmod|exec\(\"rm" — FS via shell instead of native PHP.

Output format

The report template below is the exact shape the agent must produce. Translate section names to the user's language at emit time; keep code identifiers and config keys verbatim.

# Symfony Security Audit — <repo-name>

**Date:** YYYY-MM-DD
**Symfony:** <version from composer.json>
**Scope:** <n controllers, n entities, n twig, n yaml files read>

## TL;DR

- <top finding 1 with severity>
- <top finding 2>
- <top finding 3>

## Configuration baseline

| Control | Status | Evidence |
|---|---|---|
| login_throttling | ✓ / ✗ / partial | `config/packages/security.yaml:L12` |
| cookie_samesite=lax | ... | ... |
| NelmioSecurityBundle | ... | `composer.json` |
| remember_me token_provider doctrine | ... | ... |
| trusted_hosts | ... | ... |
| .env.local in .gitignore | ... | `.gitignore:L3` |

## Findings

### Critical

#### C-01 · <short title>
- **Pattern:** <1-10 or deep dive 1-4>
- **File:** `src/.../Foo.php:42`
- **Symptom:** <1-2 lines citing the code or the missing control>
- **Remediation:** <1 line, taken from the pattern table / deep dive>

### High
...

### Medium
...

### Low
...

## Suggested actions (by impact)

1. <action> — covers N findings · affects `<category>`
2. <action>
3. ...

## Recommended regression tests

- IDOR: endpoints `<list>` — test that a user in tenant A gets 403 when requesting resources in tenant B.
- CSRF: forms `<list>` — test that POST without token fails.
- SSRF: validator — test with `127.0.0.1`, `localhost`, `169.254.169.254`.

## Methodology notes

<one line if scope was partial or likely false positives>

Severity criteria

  • Critical — exploitable IDOR (entity loaded by ID without ownership), SQL injection, auth bypass, potential RCE.
  • High — reflected XSS, exploitable SSRF, plaintext credentials, missing rate limit on login / signup.
  • Medium — missing security headers, weak password policy, incomplete cookie flags, unsigned webhook.
  • Low — accessible debug routes, theoretical path traversal, verbose metadata logging.
  • If undecidable from code alone: "needs user verification".

Rules the agent must follow

  • Read-only. Never edit, write, run shell that modifies state, or open PRs.
  • Evidence-based. Every finding cites file:line. No evidence → no finding.
  • Honest. If something cannot be determined from code, say "needs user verification" — never guess.
  • Structured output. Produce the report in the format above. No preamble, no process narration.
  • No auto-fix. The user picks which findings become PRs.

Do NOT include in the report

  • Your process (what greps you ran, what you read).
  • "I suggest you open a PR..." — you report, the user decides.
  • Inline code patches. Only the one-line remediation from the pattern table / deep dive.
  • Findings without file:line.
  • Subjective judgments ("smells bad"). Objective code signals only.

Honest limits

If the codebase is large (> 500 PHP files), do a reasoned sample: all controllers, all entities, templates containing |raw, configs in full. Document in "Methodology notes" what was left out.


Using this skill

Claude Code

Copy this skill into your project, or reference it from a central location:

cp -r skills/symfony-security-audit /your-project/.claude/skills/

Invoke it by asking the agent: "audit my Symfony project using the symfony-security-audit skill, in pre mode" (or existing).

For mode existing in large codebases, ask the agent to delegate the scan to a sub-agent via the Agent tool so the main conversation stays clean.

Cursor / Windsurf

Add the SKILL.md to your project context (.cursor/rules/ or .windsurfrules). Ask the agent to apply it when auditing a Symfony project.

Generic agents

Include the file in your system prompt or project documentation. Ask the agent to run the scan plan and return the report in the specified format.


Changelog

  • 1.0.0 (April 2026) — initial release. 10 patterns + 4 deep dives + 5-block scan plan + strict report template.