Symfony Security can feel like someone handed you a keyring with 40 keys and said, "The correct door is obvious."
It is not obvious at first.
But the component becomes much easier once you separate two questions: who is this user, and what are they allowed to do?
That's authentication and authorization. Most pain comes from mixing them together.
Authentication Answers "Who Are You?"
Authentication verifies identity. Login form, API token, JWT, session cookie, SSO callback — different doors, same question.
In Symfony, a firewall defines the security behavior for a part of your application. It decides which requests are covered and which authentication mechanisms apply.
security:
firewalls:
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\ApiTokenAuthenticator
main:
lazy: true
form_login:
login_path: app_login
check_path: app_login
This says API requests use a stateless authenticator, while the main web area can use form login.
A firewall is like the front desk of a building. It does not decide whether you can enter every room; it first confirms who you are.
Authenticators Turn Requests Into Users
An authenticator looks at the request and tries to authenticate the user.
For an API token, that might mean reading an Authorization header, finding a token record, and returning a user badge.
public function supports(Request $request): ?bool
{
return $request->headers->has('Authorization');
}
public function authenticate(Request $request): Passport
{
$token = str_replace('Bearer ', '', $request->headers->get('Authorization'));
return new SelfValidatingPassport(
new UserBadge($token, fn (string $token) => $this->users->findByApiToken($token))
);
}
The exact implementation depends on your app, but the shape is stable: inspect request, build passport, resolve user.
Authorization Answers "Can You Do This?"
Once Symfony knows the user, authorization decides what they can access.
Roles are the simplest tool.
security:
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/account, roles: ROLE_USER }
That works for broad gates. Admin area? Require admin. Account area? Require logged-in user.
But real products rarely stop there.
Roles Are Not Permissions
A role is a broad label. A permission is a decision in context.
ROLE_MANAGER may be allowed to edit one project but not another. ROLE_USER may view their own invoice but not someone else's. Hardcoding that into roles gets ugly fast.
That's where voters shine.
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'VIEW' && $subject instanceof Invoice;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $subject->belongsTo($user);
}
Now your authorization logic lives in one reusable place instead of being scattered across controllers.
#[Route('/invoices/{id}')]
public function show(Invoice $invoice): Response
{
$this->denyAccessUnlessGranted('VIEW', $invoice);
return $this->render('invoice/show.html.twig', [
'invoice' => $invoice,
]);
}
This reads like business language: deny access unless the user can view this invoice.
Access Control Is The First Gate, Not The Whole System
access_control rules are great for route-level protection. They are not enough for object-level decisions.
Think of access_control as the building entrance. Voters are the locked rooms inside.
security:
access_control:
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api, roles: PUBLIC_ACCESS }
This kind of rule can protect whole areas. But it cannot answer "Can this user approve this refund?" without knowing the refund.
Use both levels intentionally.
Password Hashing Is A Moving Target
A new application has it easy: pick a hashing algorithm, store hashes, move on. Real apps live for years, and "the best algorithm" changes during that time.
Symfony Security supports automatic password hash upgrades for exactly this reason. You configure the algorithm you want today, and when a user logs in with an old hash, Symfony rehashes their password using the new algorithm transparently.
security:
password_hashers:
App\Entity\User: 'auto'
'auto' lets Symfony pick the best available algorithm — currently bcrypt or argon2id depending on your PHP build. To opt into automatic rehashing, your User entity implements PasswordAuthenticatedUserInterface and your authenticator uses UserBadge with a password credential. Symfony handles the rest.
For password updates outside login (admin-set passwords, password reset flows), use the hasher service:
public function __construct(
private UserPasswordHasherInterface $hasher,
) {}
public function change(User $user, string $newPlainPassword): void
{
$hashed = $this->hasher->hashPassword($user, $newPlainPassword);
$user->setPassword($hashed);
}
Two rules worth following:
- Never store the algorithm choice in the user record. Let configuration decide; that's how you migrate without data surgery.
- Have a plan for forced password resets. Algorithm upgrades happen during login. If a user never logs in again, their old hash never gets upgraded. For high-security systems, force a reset after a known-bad algorithm is deprecated.
Stateless API Auth Patterns
Web sessions and API tokens are different security models, and trying to make one firewall serve both usually ends in pain. The cleanest pattern is two firewalls, each tuned for its world.
security:
firewalls:
# API: stateless, token-based, no session
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\ApiTokenAuthenticator
# Web: session-based, form login, CSRF
main:
lazy: true
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
stateless: true is the important word. It tells Symfony not to start a session for API requests, not to set session cookies, and not to look for a stored token in the session storage. Every API request stands on its own — the only thing that authenticates it is the header it sent.
Three patterns work well for the token itself, depending on the trust level you need:
- Long-lived API tokens stored in the database. Simple, easy to revoke, ideal for first-party mobile apps and personal access tokens.
- JWT (JSON Web Tokens). Self-contained, no database lookup per request, harder to revoke. Good for public APIs at scale, but you need a real plan for revocation (short expiry + refresh tokens, or a deny-list).
- OAuth2 access tokens via a dedicated server. Heavier, but the right answer when third parties need scoped access.
Whichever you pick, the API firewall should never share storage or session with the web firewall. A logged-out web session must not implicitly invalidate API tokens, and a revoked API token must not log the user out of the website. Two firewalls, two lifecycles.
Common Security Problems
- Using roles for everything — Roles become unmaintainable when they try to express every business rule.
- Putting permission logic in Twig — Hiding a button is not the same as protecting an action.
- Skipping object-level checks —
/invoices/123needs resource authorization, not only route authorization. - Mixing API and web security accidentally — Stateless API auth and session-based web auth often deserve separate firewalls.
- Making voters too broad — A voter should answer focused questions, not become a permission god object.
Security bugs often come from "almost clear" boundaries.
Pro Tips
- Name attributes clearly —
VIEW,EDIT,APPROVE, andCANCELare easier to reason about than vague names. - Check permissions near the action — The closer the authorization check is to the protected operation, the safer it is.
- Use voters for domain decisions — If the answer depends on the user and a resource, a voter is usually a good fit.
- Keep authentication boring — Fancy login flows are fine, but identity resolution should be predictable.
- Test denied paths — A security test that proves "user cannot do X" is often more valuable than the happy path.
Final Tips
I've seen apps where everyone could explain the login flow, but nobody could confidently explain why a user could edit a specific record. That is where security gets risky.
Keep the model simple: firewalls and authenticators identify users; access control protects broad areas; voters make contextual decisions.
Security does not need to feel painful. Make the gates explicit and keep shipping safely 👊




