symfony/security-core
Symfony's core security library provides the infrastructure for authentication and authorization: user providers, tokens, voters, role hierarchies, and access decision management, decoupled from your application's actual authorization logic.
Install the right package. In a Symfony application you almost never install symfony/security-core directly — you install symfony/security-bundle, which pulls in core plus security-http, security-csrf and wires everything through the container:
composer require symfony/security-bundle # normal app
composer require symfony/security-core # framework-agnostic libs, custom auth stacks
Core on its own gives you authorization primitives (voters, role hierarchy, access decision manager), user/password abstractions (providers, hashers, user checkers) and token classes. It does not give you firewalls, HTTP authentication, session token storage or is_granted() in Twig — those live in security-http/security-bundle.
Where to look first. Read the Symfony Security docs, then open config/packages/security.yaml and run bin/console debug:firewall main. If you're building standalone, start with the README example: AccessDecisionManager + AuthenticatedVoter/RoleVoter/RoleHierarchyVoter, then hand it a UsernamePasswordToken. The class you will actually extend most often is Symfony\Component\Security\Core\Authorization\Voter\Voter.
First use case: a domain voter. Almost every real app needs authorization that isn't role-based (can this user edit *this* post?). That is a voter, and it plugs into is_granted('POST_EDIT', $post):
final class PostVoter extends Voter
{
public const VIEW = 'POST_VIEW';
public const EDIT = 'POST_EDIT';
protected function supports(string $attribute, mixed $subject): bool
{
return $subject instanceof Post && \in_array($attribute, [self::VIEW, self::EDIT], true);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false; // deny-by-default
}
return match ($attribute) {
self::EDIT => $subject->getAuthor() === $user
|| \in_array('ROLE_ADMIN', $token->getRoleNames(), true),
self::VIEW => $subject->isPublished() || $subject->getAuthor() === $user,
};
}
}
Then in a controller: $this->denyAccessUnlessGranted(PostVoter::EDIT, $post); or #[IsGranted(PostVoter::EDIT, subject: 'post')].
Voter-per-bounded-context. One voter per aggregate (OrderVoter, ProjectVoter), attribute constants on the voter, supports() narrowed by class and attribute list. Keep voteOnAttribute() free of side effects; it can be called more than once per request when is_granted() appears in Twig layouts, voters and controller code.
Declarative first, imperative second. Use #[IsGranted] / access_control for coarse, URL- or action-level gates, and denyAccessUnlessGranted() / $this->isGranted() when the decision depends on a loaded entity. Reach for AuthorizationCheckerInterface directly only in services where you can't inject the decision result.
Role hierarchy. Define parent/child relations once and let the framework expand them:
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
Hierarchy is applied by the role voters — i.e. through is_granted(). If you need it manually, inject RoleHierarchyInterface and call getReachableRoleNames($user->getRoles()); never in_array('ROLE_ADMIN', $user->getRoles()).
Pick a decision strategy deliberately. The default is affirmative (one grant wins). unanimous is attractive for "all policies must pass" but is easy to break with a third-party voter that denies; priority gives first-non-abstaining-voter-wins and therefore makes voter ordering part of your security model (register with voter priority). In Symfony 8.x the Vote object carries extraData, letting you write a custom AccessDecisionStrategy without subclassing the manager.
Account lifecycle via user providers and checkers. PasswordUpgraderInterface on your provider + UserPasswordHasherInterface gives transparent rehashing on login (migrateFrom for legacy formats). A custom UserCheckerInterface is the right place for "locked / disabled / email not verified" logic — note it runs on every authentication, including programmatic loginUser() in tests, so keep it cheap and don't put rate limiting there.
Custom authentication. Build Passports and Badges (core interfaces) inside an AuthenticatorInterface (security-http) rather than reimplementing token creation. Let the authenticator manager own the token; you should rarely construct UsernamePasswordToken outside tests and standalone usage.
Testing. Voters are pure enough to unit test — new UsernamePasswordToken($user, 'main', $user->getRoles()) and assert ACCESS_GRANTED/ACCESS_DENIED/ACCESS_ABSTAIN. Functional tests use $client->loginUser($user); for impersonation, assert on ROLE_PREVIOUS_ADMIN.
Multi-tenant / contextual attributes. Pass the subject, not just a string: is_granted('POST_EDIT', $post) scales to tenant-aware voters because the voter can inspect $subject->getTenant(). Don't encode tenant in role names (ROLE_TENANT_42_ADMIN) — that explodes the hierarchy and the session.
is_granted() is undefined, denyAccessUnlessGranted() is missing, or no security.token_storage service exists, you installed the component without the bundle. Nothing in core bootstraps itself.ROLE_ prefix is load-bearing. RoleVoter only votes on attributes beginning with ROLE_; anything else abstains. is_granted('ADMIN') therefore falls through to a 403 with no useful explanation — a classic half-hour of debugging. Voter constants like POST_EDIT are fine, but they must be handled by your voter.false from voteOnAttribute() is a deny; returning false from supports() is an abstain. With affirmative + allow_if_all_abstain: false, everything abstaining still denies — so a forgotten supports() case looks identical to an intentional deny at the HTTP layer.supports() must be a type/attribute check, never a database hit. If you implement CacheableVoterInterface, be honest about which attributes and subject types you support; returning true for everything silently disables the optimisation and can serve stale decisions.$user->getRoles() checks bypass the hierarchy entirely. Route every ROLE_ check through is_granted() or RoleHierarchyInterface.refreshUser, EquatableInterface) over trusting a stale serialized object. Also note 8.0 dropped the user FQCN from remember-me cookies — a rolling deploy across that boundary logs everyone out.IS_AUTHENTICATED* semantics. IS_AUTHENTICATED_FULLY ≠ IS_AUTHENTICATED_REMEMBERED ≠ IS_AUTHENTICATED. Remember-me tokens pass the latter two but not the first; use IS_AUTHENTICATED_FULLY for anything destructive (password change, payments) and remember that AuthenticationTrustResolver is what decides this — you can override it as an extension point.#[IsGranted("is_granted('ROLE_X') and ...")] and allow_if: in access_control require symfony/expression-language. Without it you get an unhelpful runtime error, not a config-time one.debug:firewall <name> (listeners, authenticators, access map), debug:container --tag=security.voter (is your voter registered? autoconfiguration only picks up Voter subclasses — custom VoterInterface implementations need the security.voter tag), the profiler's Security panel for the token and the actual vote, and debug:container --parameter=security.role_hierarchy. Symfony 8.x can dump the role hierarchy as a Mermaid chart, which is far faster to eyeball than YAML for deep trees.max_uses races, impersonation deauthentication, lazy-firewall session tracking). Many entries in those changelogs are CVEs — treat composer update symfony/security-* as maintenance, not optional.Voter, AccessDecisionStrategy, UserProviderInterface (chain providers for legacy migrations), PasswordHasherInterface, UserCheckerInterface, AuthenticationTrustResolverInterface, and SignatureHasher for signed URIs.How can I help you explore Laravel packages today?