| name | symfony-security |
| description | Symfony / PHP webapp security review — Security Component (firewalls, voters, access_control, role hierarchies), Doctrine ORM injection patterns (raw DQL, QueryBuilder, expr()), Twig auto-escape and |raw, CSRF + session, PHP-specific RCE classes (unserialize, include/require, system/exec, eval, type juggling), configuration discipline (.env, secrets vault, profiler in prod), and recent Symfony CVE patterns. Covers Symfony-based CMSes (Sulu, Ibexa, Bolt) and custom Symfony webapps. |
Symfony Security
When to use
This skill is the Symfony/PHP-specific layer on top of secure-coding. Symfony has a sophisticated Security Component but plenty of foot-guns: firewall ordering, missing voters, Doctrine string-interpolation, Twig opt-outs, and PHP's enduring RCE classes (unserialize gadget chains, dynamic includes, type juggling).
Triggers on:
- A question like "review this Symfony app for security", "is our security.yaml correct", "Doctrine SQLi check", "Twig |raw on user input", "PHP unserialize on request body", "Symfony Profiler in prod".
- Presence of
composer.json with symfony/* packages, config/packages/security.yaml, config/services.yaml, src/Controller/, src/Entity/, templates/*.twig, bin/console.
- A PR that touches
security.yaml, voter classes, Doctrine repositories with raw SQL or DQL, Twig templates with |raw, or any controller that calls unserialize(), include $var, system(), or eval().
- Symfony version bumps or security advisories from the Symfony blog.
- A handoff from
security-review or api-security when Symfony is in the stack.
- A Symfony-based CMS (Sulu, Ibexa, Bolt) review.
When NOT (handoff)
- General PHP secure-coding (not Symfony-specific) →
secure-coding.
- API design and OWASP API Top 10 →
api-security. Use this skill for the Symfony-specific implementation; that skill for the conceptual API layer.
- SAST tooling (Psalm, PHPStan, Phan with security rules, Semgrep
p/php) → sast-orchestrator.
- Dependency vulns in
composer.lock → cve-triage.
- Container/host hardening for the PHP-FPM + nginx/Apache stack →
container-hardening.
- Secrets in
.env, parameters.yaml, or external vault → secrets-scanner.
- Drupal specifically — uses many Symfony components but has fundamentally different architecture (services, hooks, routing). Out of scope here; warrants its own skill in a future round.
- WordPress, Laravel — different framework families; this skill stays focused on Symfony and Symfony-based CMSes.
Approach
Eight phases. Phase 1 (security.yaml) is where most production bugs sit; phase 5 (PHP RCE classes) is where Symfony skill diverges most from the other framework-specific skills.
1. Security Component: security.yaml
Symfony's Security Component is configured in config/packages/security.yaml. The structure:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity: { class: App\Entity\User, property: email }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
entry_point: form_login
form_login: { ... }
logout: { ... }
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_ADMIN: [ROLE_USER]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
Common foot-guns:
- Firewall order matters. Symfony walks the
firewalls map top-down and applies the first match. A broad pattern early (e.g. pattern: ^/) eats subsequent firewalls. Specific patterns first, generic last.
dev firewall in production. The dev firewall has security: false for the profiler/asset paths. If somehow dev patterns leak into prod (mistyped pattern, copy-paste), parts of the site become unauthenticated. Cross-check the prod environment loads security.yaml without dev overrides.
access_control is order-sensitive too. First matching rule wins; the rest is ignored. A path: ^/api rule above a path: ^/api/admin, roles: ROLE_ADMIN rule means admin endpoints are only IS_AUTHENTICATED, not ROLE_ADMIN.
IS_AUTHENTICATED levels: IS_AUTHENTICATED_REMEMBERED < IS_AUTHENTICATED_FULLY < IS_AUTHENTICATED_ANONYMOUSLY. For sensitive actions use FULLY; remembered tokens are weaker (cookie-based, can be stolen without re-auth).
role_hierarchy uses arrays of "roles inherited by this role". A common bug: ROLE_ADMIN: ROLE_USER (string instead of [ROLE_USER]) silently fails for ROLE_USER inheritance. Always use array notation.
- Anonymous auth: in newer Symfony (5.3+) anonymous is gone; access is implicit when no firewall matches. Pre-5.3, an
anonymous: ~ line in firewall config is intentional but worth flagging.
2. Voters and method-level authorization
Route-level access_control is coarse. Sensitive actions need voters.
final class PostVoter extends Voter
{
public const EDIT = 'POST_EDIT';
protected function supports(string $attribute, $subject): bool
{
return $attribute === self::EDIT && $subject instanceof Post;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) return false;
return $subject->getAuthor() === $user;
}
}
Then in the controller:
#[IsGranted('POST_EDIT', subject: 'post')]
public function edit(Post $post): Response { ... }
- IDOR fix is exactly the voter pattern — check ownership against the authenticated user, not just authentication.
- Without voters, controllers tend to do
if ($post->getAuthor() === $user) inline. Works, but easy to forget on a new endpoint. The voter centralizes the rule.
#[IsGranted] attribute (PHP 8+) or $this->denyAccessUnlessGranted() in older code. Pick one style and apply consistently.
3. Doctrine ORM and SQL injection
Doctrine parameterizes by default through DQL placeholders and QueryBuilder parameters. SQLi arises where you step out.
Safe:
$qb->where('u.email = :email')->setParameter('email', $email);
$conn->executeQuery('SELECT * FROM users WHERE email = ?', [$email]);
Unsafe:
$qb->where("u.email = '{$email}'");
$conn->executeQuery("SELECT * FROM users WHERE email = '$email'");
$qb->orderBy($request->query->get('sort'));
Specific patterns:
orderBy / addOrderBy with user input: same risk as Django .order_by(request.GET.get('sort')). Allowlist column names.
expr()->literal($var): uses the platform's quote function, not parameter binding. Better than string concatenation but parameter binding via setParameter is preferred.
Connection::executeQuery / executeStatement with raw SQL: always use ? or :name placeholders + parameter array.
- Native SQL via
EntityManager::createNativeQuery: same parameter discipline.
- Doctrine annotations/attributes do not protect against query-time injection; they only define schema. The query itself must be parameterized.
4. Twig templates and XSS
Twig auto-escapes for the active context (HTML, JS, CSS, URL). XSS arises where you opt out.
{{ var|raw }} — opt-out of escaping. Treat every |raw filter on user input as XSS.
{% autoescape false %} block — disables escaping for the entire block. Rare, review every use.
{{ var }} in JS context: Twig auto-detects <script> blocks and applies JS escaping. But <script>var x = {{ data|json_encode|raw }}</script> is the safer pattern (JSON serializer escapes correctly, |raw here is intentional because json_encode already produced safe output).
- HTML attribute context: Twig handles
<a href="{{ url }}"> correctly by default for HTML quoting, but javascript: URLs in href bypass HTML escaping. Validate url server-side (allowlist http:, https:, mailto:).
- Custom Twig functions/filters: code that returns
new \Twig\Markup($value, 'UTF-8') marks content as already-safe. If $value includes user input, that is XSS. Audit Twig\Markup instantiation.
- Symfony UX components / Twig live components: user-controlled props that flow into rendered output need the same escape discipline as classic templates.
5. PHP-specific RCE and dangerous primitives
This is the phase where Symfony skill diverges most from the Java/Python/Ruby framework skills. PHP has several features that turn user input into code execution if mishandled.
unserialize() on user input — RCE via gadget chains:
- PHP class instantiation invokes
__wakeup, __destruct, __toString, etc. magic methods.
- A POP (Property-Oriented Programming) chain across classes already in the codebase can produce arbitrary effect from controlled
unserialize() input.
- Never call
unserialize() on user input. If you must deserialize untrusted data, use json_decode (returns scalars/arrays only, no class instantiation) or a strictly-validated JSON schema.
- The
allowed_classes parameter (unserialize($data, ['allowed_classes' => false])) blocks class instantiation but is a brittle defense — json_decode is cleaner.
- Common attack surface: cookies, request parameters, cached data with controllable inputs, message-queue payloads. Search the codebase for
unserialize(.
include / require with user input — LFI/RFI:
include $_GET['template'];
include $base . $request->get('p');
- Validate against an allowlist of known templates. Never resolve user-controlled path fragments into file system paths.
allow_url_include should be Off in php.ini — historic mitigation against RFI but never the only defense.
system, exec, passthru, shell_exec, backticks — command injection:
eval() — direct code execution:
- Never on user input.
- In legacy code, sometimes used for simple template engines or expression evaluation. Replace with Symfony's Expression Language component (
ExpressionLanguage) — sandboxed, no PHP code execution.
Type juggling and loose comparisons:
== does type coercion: '0' == false is true, 'abc' == 0 is true (in PHP <8), '1abc' == 1 is true.
- For auth/comparison logic, always use
=== (strict).
in_array($needle, $haystack) defaults to loose comparison — pass true as third argument for strict.
hash_equals($known, $user_supplied) for timing-safe comparison of hashes/tokens.
6. CSRF, session, and password handling
- CSRF on forms: Symfony Form Component injects a
_token field automatically. Validate via $this->isCsrfTokenValid('intention', $token) for forms not built with the Form Component (e.g. AJAX endpoints).
- API stateless endpoints typically skip CSRF (token-auth or JWT). Make this explicit per firewall —
stateless: true in the firewall config.
- Session config (
config/packages/framework.yaml):
cookie_secure: auto or true for HTTPS-only
cookie_httponly: true
cookie_samesite: lax or strict
gc_maxlifetime reasonable (e.g. 1 hour)
name_strict_mode: true to prevent session-fixation via attacker-supplied session ID
- Password hashing: use
auto algorithm (Symfony picks bcrypt or argon2id based on what's available). Cost factor configurable; argon2id with memory-cost 65536+ is the current default.
- Remember-me tokens: signed via secret; rotate the secret on suspected compromise. Token storage in DB > cookie-only (allows revocation).
7. Configuration discipline
.env / .env.local / .env.local.php: .env is the public default, .env.local is gitignored secrets, .env.local.php is the compiled production cache (run composer dump-env prod).
- Symfony Secrets vault (
bin/console secrets:set): encrypts secrets per-environment with separate decryption keys. Production decryption key stored outside the repo.
parameters.yaml (legacy, pre-Flex): hardcoded secrets in here are an anti-pattern. Migrate to .env or secrets vault.
- Web Profiler in production:
WebProfilerBundle exposes /_profiler with full request internals (DB queries, log entries, env vars). MUST be disabled in prod (framework.profiler.enabled: false and remove from bundles.php for prod env).
app_dev.php legacy (Symfony 2/3 era): the dev front controller exposing the profiler. Modern Symfony (4+) uses environment-based switching, but legacy apps may still ship app_dev.php to production by mistake. Should not be reachable.
- Debug mode:
APP_ENV=prod APP_DEBUG=0 in production. Debug mode shows stack traces with secrets and code paths.
- Trusted proxies and trusted hosts:
framework.trusted_proxies and framework.trusted_hosts lock down which proxies and hosts the app trusts. Misconfig = host-header injection vector.
8. CVE patterns and Symfony-based CMSes
Symfony CVE feed: https://symfony.com/blog/category/security-advisories is the canonical source. Always [verify] against the current advisory list before citing in a finding — Symfony patches in minor-release windows and the advisory list moves.
Recent CVE classes worth grepping for:
- Cache-poisoning and session-fixation patterns from late 2022 —
[verify against the advisory feed].
- HTTP-cache + ESI-related disclosure issues —
[verify].
- Environment-mode override via query parameters in development —
[verify].
Practical defense: stay within 1 minor release of latest. Track the security-advisories blog category; subscribe to the RSS feed.
Symfony-based CMSes (this skill applies, with CMS-specific overlays):
- Sulu — REST-API-first CMS, security via security.yaml + Sulu's role/permission system. Permissions are per-content-locale and per-section. Common review targets: webspace configuration, custom controllers extending Sulu's base, REST-API endpoint exposure.
- Ibexa Platform (formerly eZ Platform / eZ Publish) — has its own role/policy system on top of Symfony's, plus content-tree permissions. Custom field-types and view-handlers are common attack-surface entry points. Older eZ Publish (pre-Ibexa) had a string of CVEs; Ibexa modernized but legacy patterns persist in long-running deployments.
- Bolt CMS — lighter Symfony-based CMS. Smaller attack surface but the same Symfony-component patterns apply.
- Drupal 9+ — explicitly out of scope here. Uses Symfony components but its security model (hooks, services, render arrays, FormAPI) is its own world. Drupal has a dedicated Security Team and SA advisory feed; treat as a separate skill.
- Custom Symfony webapps — the bulk of real-world cases. The discipline here applies directly.
Verification-loop
Layer 1: scope (security.yaml, all controllers with #[IsGranted] or auth checks, all Doctrine repositories with custom queries, all Twig templates using |raw, all calls to unserialize/include/system/eval walked through?), assumptions (firewall order verified, voter coverage on resource-modifying endpoints?), gaps (Profiler disabled in prod, debug off, secrets in vault?). Layer 2: Symfony CVE-IDs verified against https://symfony.com/blog/category/security-advisories (no fabricated IDs), Doctrine and Twig API names current for the version in use, CMS-specific role-system claims (Sulu, Ibexa) backed by their own docs.
Output
Symfony security review — <app/module>
Symfony: <x.y.z> | PHP: <x.y> | CMS: <Sulu | Ibexa | Bolt | none (custom)>
security.yaml:
Firewall count: N
Firewall order audit: <clean | finding: pattern X eats Y>
access_control rules: N, ordered specific→generic? <yes/no>
role_hierarchy: <correct array notation | finding>
IS_AUTHENTICATED_FULLY on sensitive routes: <yes/no>
Voters and authorization:
Voter classes: N
Coverage on resource endpoints: <% with #[IsGranted] or denyAccessUnlessGranted>
Inline ownership checks (no voter): <list — refactor candidates>
Doctrine:
Raw DQL/SQL with string interpolation: <list>
orderBy with user input: <list, allowlist applied?>
expr()->literal usage: <count, replacement plan>
Twig:
|raw on user input: <list>
autoescape false blocks: <list>
Twig\Markup wrappers: <audit>
PHP RCE primitives:
unserialize() calls: <list, source of input>
include/require with $var: <list>
system/exec/passthru: <list, Process component used?>
eval(): <should be 0; if not, FINDING>
Strict comparison (===): <audit auth/token comparisons>
CSRF + session:
CSRF token validation on non-Form endpoints: <yes/no>
cookie_secure / httponly / samesite: <yes/no per setting>
Password algorithm: <auto | argon2id | bcrypt | weak>
Configuration:
Profiler in prod: <disabled | FINDING if enabled>
APP_DEBUG in prod: <0 | FINDING if 1>
Secrets in .env vs vault: <distribution>
Trusted proxies/hosts: <set | not set — header-injection risk>
Version + CVE check:
Symfony version: <on latest minor + N | drift>
Recent advisories: <0 unresolved | N pending — handoff to cve-triage>
CMS-specific (if applicable):
Sulu role system: <reviewed>
Ibexa policies: <reviewed>
Findings (severity-sorted, follow security-review format)
Verification-loop: ...
References
Categories