Symfony's SecurityBundle gives PHP teams a mature authentication and authorisation model, but most real weaknesses come from how firewalls, access control rules and voters are configured rather than from the framework itself. An unanchored path in access_control, a role check that ignores the hierarchy, an impersonation feature left open to too many roles, or a production decryption key committed to Git can each undo the rest of the design. Symfony also underpins other PHP platforms and frameworks, so the same patterns turn up well beyond pure Symfony applications. This guide covers how identity and access work in Symfony, privileged roles and impersonation, API tokens and the secrets vault, logging, a hardening baseline and the misconfigurations reviewers find most often. It is written for PHP developers, application security engineers and code reviewers, and it goes deeper than the PHP and WordPress Security Guide.
Key takeaways
- One firewall per request, first match wins. Symfony uses the first firewall whose
patternmatches and the firstaccess_controlrule that matches, so ordering and regex anchors are security decisions. - Use isGranted, never role arrays. Checking
in_array('ROLE_ADMIN', $user->getRoles())bypasses the role hierarchy and voters;isGranted(),#[IsGranted]anddenyAccessUnlessGranted()do not. - Voters carry object-level rules. Access to a specific record belongs in a voter, and the decision strategy (affirmative by default) changes what happens when voters disagree.
- The prod decryption key is the crown jewel. The secrets vault is only as safe as
prod.decrypt.private.phporSYMFONY_DECRYPTION_SECRET, which must never reach Git, developer laptops or CI logs. - Debug tooling must not ship. Symfony's own documentation says never to enable the profiler in production, and
APP_ENV=prodwithAPP_DEBUG=0is the baseline.
How identity and access work in Symfony
Configuration lives in config/packages/security.yaml. Four building blocks decide who a user is and what they may do.
| Component | What it does | Security-relevant behaviour |
|---|---|---|
| User provider | Loads users (entity, LDAP, memory, chain or custom) | Each firewall has exactly one provider; use a chain provider to combine several |
| Password hashers | Hash and verify passwords | 'auto' selects the best available algorithm and migrates hashes on login when the repository implements PasswordUpgraderInterface |
| Firewalls | Choose how a request is authenticated | Only one firewall handles a request: the first whose pattern matches. A firewall with no pattern matches everything and must come last. The dev firewall with security: false should match only ^/(_profiler|_wdt|assets|build)/ |
| Authenticators | form_login, json_login, http_basic, login_link, access_token, x509, remote_user, custom | Each builds a passport with badges (CSRF, remember-me, password upgrade) that listeners check |
| Access control and voters | Decide authorisation | access_control is URL-level and first match wins; voters decide object-level attributes through the access decision manager |
Authentication attributes
| Attribute | Granted when | Use for |
|---|---|---|
PUBLIC_ACCESS | Always, including anonymous visitors | Login pages, health checks |
IS_AUTHENTICATED | Any authenticated user | General member areas |
IS_AUTHENTICATED_REMEMBERED | Authenticated, possibly only by remember-me cookie | Low-risk pages |
IS_AUTHENTICATED_FULLY | Signed in during this session, not by remember-me | Password change, payment, admin actions |
IS_REMEMBERED | Authenticated only through the remember-me cookie | Prompting re-authentication |
IS_IMPERSONATOR | The session is impersonating another user | Blocking sensitive actions during impersonation, showing an exit link |
Access control rules
- Anchor every path: paths are regular expressions, and Symfony's documentation notes that
/adminwithout^also matches/foo/admin. Write^/admin. - Order specific before general: because only the first match applies, a
^/admin/loginrule withPUBLIC_ACCESSplaced after^/adminnever takes effect, and a broad public rule placed first can expose everything beneath it. - Combine matchers deliberately: rules can match on
ips,hostandmethodsand can force HTTPS withrequires_channel. An IP restriction on an internal admin path is a useful extra layer, never the only one. - Treat access_control as coarse: it protects URL areas. Record-level decisions go in voters, checked in the controller or service.
Voters and decision strategies
A voter extends Voter and implements supports() (return false to abstain) and voteOnAttribute() (return true to grant). Voters can attach reasons with $vote->addReason(), which appear in logs and on exception pages in development.
| Strategy | Grants when | Choose it when |
|---|---|---|
affirmative (default) | At least one voter grants | Few voters with clearly separate attributes |
consensus | More voters grant than deny; ties follow allow_if_equal_granted_denied (default true) | Rarely; ties default to grant |
unanimous | No voter denies | Any voter should be able to veto, for example a tenant-isolation voter |
priority | The first non-abstaining voter by service priority decides | Layered rules where order matters |
If every voter abstains, allow_if_all_abstain decides, and its default is false. Keep it false: an attribute with a typo should deny. Inside a voter, check roles through AccessDecisionManagerInterface::decide(), not Security::isGranted(), which Symfony warns may not use the same token. See the Broken Access Control Prevention Guide.
Admin roles and privileged access
- Role hierarchy:
role_hierarchyis static configuration, for exampleROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]. Review it like a permission matrix; one line can hand a large population a powerful role. - Full authentication for admin: require
IS_AUTHENTICATED_FULLYalongside the admin role, so a stolen remember-me cookie cannot reach admin functions. - Impersonation:
switch_userlets holders ofROLE_ALLOWED_TO_SWITCHact as any user by adding_switch_user=<identifier>to a URL (or a header such asX-Switch-Userif configured), and_exitends it. Grant the role to a small support group, or replace it with a custom attribute such asCAN_SWITCH_USERchecked by a voter that also refuses to impersonate administrators. - Audit impersonation: the token becomes a
SwitchUserToken; recordgetOriginalToken()->getUser()as the actor in every audit event, and listen to thesecurity.switch_userevent to log start and exit. - Block dangerous actions while impersonating: deny password, MFA and payout changes when
IS_IMPERSONATORis granted. - Revocation: on every request Symfony refreshes the user from the provider and, by default, logs the user out if the password, salt or user identifier changed. Implement
EquatableInterfaceto also end sessions when roles are removed or the account is disabled, and use a user checker to reject disabled accounts at login.
See the Privileged Access Management Guide for the wider controls around admin accounts.
Integrations, API tokens and service accounts
Access token authentication
- Handler: the
access_tokenauthenticator calls atoken_handlerimplementingAccessTokenHandlerInterfacethat returns aUserBadge. Symfony's documentation warns to always verify the token: expiry and revocation for opaque tokens, and signature plussub,iat,nbfandexpfor JWTs. - Extractors: the default
headerextractor readsAuthorization: Bearer. Thequery_stringandrequest_bodyextractors should not be used unless a header is impossible, because URLs and bodies are often logged. - OIDC: the built-in
oidchandler validates JWTs locally and requiresaudienceandissuers; setalgorithmsto the asymmetric algorithms your provider uses. Theoidc_user_infohandler calls the UserInfo endpoint instead. See the JWT Security Guide. - Stateless firewalls: mark API firewalls
stateless: trueso no session is created and every request carries its own credential.
Secrets vault
| Item | Handling |
|---|---|
config/secrets/prod/prod.encrypt.public.php | Safe to commit; lets anyone add or change a secret without reading others |
config/secrets/prod/prod.decrypt.private.php | Never commit. Deploy as a file or as base64 in SYMFONY_DECRYPTION_SECRET. Developers and CI do not need it |
dev.decrypt.private.php | May be committed only if the dev vault holds nothing sensitive |
| Rotation | secrets:generate-keys --rotate re-encrypts with a new key pair; do it when anyone with the key leaves |
| Deploy-time decrypt | secrets:decrypt-to-local --force writes .env.prod.local so the key need not stay on the server; protect that file instead |
| Precedence | Real environment variables override vault secrets of the same name, which can silently replace a rotated value |
APP_SECRET signs URIs, login links and remember-me cookies. Generate it randomly per environment, keep it out of the repository and consistent across servers; changing it invalidates signed URIs and remember-me cookies, which makes it a useful emergency lever. The vault needs the Sodium extension. For integrations, give each external system its own credential and owner rather than reusing one shared key. See the Secrets Management Guide and the API Key Management Guide.
Logging and detection
Symfony dispatches security events on a per-firewall dispatcher (security.event_dispatcher.<firewall>), which is the cleanest hook for audit logging.
| Event or source | Record | Detects |
|---|---|---|
LoginSuccessEvent | User identifier, firewall, authenticator, IP | Logins from new locations, service accounts used interactively |
LoginFailureEvent | Attempted identifier, reason key, IP | Password spraying and stuffing |
| Login throttling hits | Rate limiter rejections | Brute force against one account or many from one IP |
security.switch_user | Impersonator, target, start and exit | Misuse of support impersonation |
AccessDeniedException (403) | Attribute, subject type, user | Users probing admin routes or other tenants' records |
Requests to /_profiler or /_wdt in production | Web server or WAF logs | Reconnaissance for exposed debug tooling |
Never log submitted passwords, tokens or error.message contents shown to users; show error.messageKey instead, as Symfony advises. See the Secure Logging Vocabulary and Application Security Events Guide.
Metrics:
- Controllers and API routes without
#[IsGranted],denyAccessUnlessGranted()or anaccess_controlrule (target zero outside public routes). - Number of users holding
ROLE_ALLOWED_TO_SWITCHor the impersonation attribute, reviewed quarterly. - Age of the production vault key pair and of
APP_SECRET. - Login failures blocked by throttling per day, and accounts hitting the limit repeatedly.
- Production responses carrying
X-Debug-Tokenheaders (target zero).
Hardening baseline
- Production mode: run with
APP_ENV=prodandAPP_DEBUG=0, install withcomposer install --no-dev --optimize-autoloader, and keepsymfony/profiler-packas a dev-only dependency underwhen@dev. Confirm/_profilerreturns 404 in production. - Firewalls: confirm the
devfirewall pattern is the default narrow one, put a catch-allmainfirewall last, make API firewalls stateless and keeplazy: truewhere pages are cacheable. - Access control: anchor every path, order specific rules first, and add a final rule that requires authentication for everything not explicitly public (deny by default).
- Login defences: enable
login_throttling(requiressymfony/rate-limiter; defaults to 5 attempts per minute, limited per IP and username and per IP at five times that), setenable_csrf: trueonform_login, and add MFA through an authenticator or bundle for admins. See the CSRF Prevention Guide. - Remember-me: shorten
lifetimefrom the one-year default, setsecure: trueand asamesitevalue, add a field such asupdatedAttosignature_propertiesor use Doctrine persistent tokens so tokens can be revoked, and give each firewall a unique cookiename. - Voters: keep
allow_if_all_abstain: false, chooseunanimousorprioritywhen a veto voter exists, and unit test each voter for grant, deny and abstain. - Secrets: move every credential into the vault or a platform secret store, add
config/secrets/prod/*.decrypt.private.phpto.gitignore, scan history for it, and rotate if it was ever committed. - Dependencies: run
composer auditin CI and track Symfony security advisories for the versions in use. See the Dependency Update Automation Guide. - Roll out safely: change
access_controland voter strategy in a staging environment with functional tests that hit every route as anonymous, user and admin; keep the previous configuration tagged so a lockout can be reverted in one deploy.
Common misconfigurations
- Unanchored or misordered rules:
path: /adminwithout^, or a broadPUBLIC_ACCESSrule above the admin rule, leaving admin routes open or protecting the wrong paths. - Profiler in production: deploying with
APP_ENV=devor the profiler bundle enabled exposes request data, configuration and logs through/_profiler, which Symfony warns leads to major vulnerabilities. - Decryption key in Git:
prod.decrypt.private.phpcommitted, orSYMFONY_DECRYPTION_SECRETprinted in CI output, making every vault secret readable to anyone with repository access. - Role arrays instead of isGranted: custom checks on
getRoles()that skip the hierarchy and voters, so inherited roles fail and denials are inconsistent. - Open impersonation:
ROLE_ALLOWED_TO_SWITCHinherited by every admin, or aswitch_userparameter header accepted from an API client, letting one account become any other. - Tokens in query strings: the
query_stringextractor enabled for convenience, leaking bearer tokens into access logs, proxies and browser history.
Framework mapping
| Symfony control | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | OWASP ASVS 5.0 |
|---|---|---|---|
| access_control, voters, IsGranted | AC-3, AC-6 | PR.AA-05 | V8.3.1 |
| Role hierarchy and impersonation limits | AC-2, AC-6(5) | PR.AA-05 | n/a |
| Login throttling | AC-7 | PR.AA-05 | n/a |
| Secrets vault and APP_SECRET | IA-5, SC-12 | PR.DS-02 | n/a |
| No profiler or debug in production | CM-7 | PR.PS-06 | n/a |
| Security event logging | AU-2, AU-12 | DE.CM-09 | n/a |
ATT&CK techniques to model are T1110 Brute Force against login forms, T1078 Valid Accounts for stolen sessions and impersonation, and T1552 Unsecured Credentials for leaked vault keys and .env files.
Where identity fits
A typical Symfony application holds several non-human credentials: the vault key pair, APP_SECRET, the database user in DATABASE_URL, mailer and queue DSNs, OAuth client secrets for social login, and API tokens issued to other systems through the access_token authenticator. Each one is an identity with access, and none of them appears in the user table. Record each in an inventory with an owner, scope and rotation date; give machine callers their own user or client record with a dedicated role rather than an admin account; issue API tokens that expire and can be revoked individually; and scan the repository and its history for .env.local, .env.prod.local and decryption key files. A database user scoped to the application schema, not the server administrator, limits the damage if a DSN leaks. See the Git Secret Scanning Guide and the Service Account Security Guide.
Practitioner checklist
- Production runs with
APP_ENV=prod,APP_DEBUG=0, no dev dependencies, and/_profilerreturns 404. - Every
access_controlpath is anchored, specific rules precede general ones, and a final rule denies by default. - All authorisation checks use
isGranted(),#[IsGranted]ordenyAccessUnlessGranted(); no code inspectsgetRoles()directly. - Object-level rules live in voters with tests, and
allow_if_all_abstainis false. - Admin routes require
IS_AUTHENTICATED_FULLYand MFA. - Impersonation is limited to a named group or a voter, logged with the original user, and blocked for sensitive actions.
- Login throttling and CSRF are enabled on every login form.
- The prod decryption key and
APP_SECRETare outside Git, stored in a secret store, and rotated on staff changes. - API firewalls are stateless, read tokens from the
Authorizationheader and validate audience and issuer. composer auditruns in CI and Symfony advisories are tracked.
Standards and references
- Symfony: Security
- Symfony: How to Use Voters to Check User Permissions
- Symfony: How to Keep Sensitive Information Secret
- Symfony: How to Impersonate a User
- Symfony: How to use Access Token Authentication
- Symfony: How to Add Remember Me Login Functionality
- Symfony: How to Deploy a Symfony Application
- OWASP: Application Security Verification Standard 5.0
Related NHI Mgmt Group resources: PHP and WordPress Security Guide · Broken Access Control Prevention Guide · Secrets Management Guide · Application Security Guide