The Security Component
How Symfony works out who is making a request: firewalls, the matcher that picks one, the token, and the user object.
Symfony\Bundle\SecurityBundle\Security::getToken(): ?TokenInterfaceSymfony's security system answers two different questions. Authentication asks who is making this request. Authorization asks whether that identity may do this particular thing. The component keeps them separate, and most early confusion comes from mixing them together.
Firewalls
A firewall is a named block of configuration describing how requests to one part of your application are authenticated. It is not a packet filter and it decides nothing about permissions. Its job is to look at the request, work out who sent it, and record the answer.
Exactly one firewall handles a request. Symfony walks the list in order and uses the first one whose matcher matches. A firewall can be limited by path with pattern, by host, by HTTP methods, or by a request_matcher service when the rule is more interesting than a regular expression.
The token
When a firewall authenticates a request it stores a token: an object implementing TokenInterface holding the authenticated user object and that user's role strings. Everything later in the request reads the token rather than the request headers. No token means nobody was authenticated, which is not the same thing as being refused.
What trips people up
A firewall with security: false blocks nothing. It switches authentication off for those paths, so no token is created and access_control rules never run there. It exists for the profiler and asset paths, not for pages you want public.
Example
<?php
// src/Service/RequestIdentity.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Bundle\SecurityBundle\Security;
final class RequestIdentity
{
// The Security helper is injected, never fetched out of the container.
public function __construct(private readonly Security $security)
{
}
/**
* @return array{authenticated: bool, identifier: ?string, roles: array<string>}
*/
public function describe(): array
{
$token = $this->security->getToken();
if (null === $token) {
// No firewall authenticated this request, or security is off here.
return ['authenticated' => false, 'identifier' => null, 'roles' => []];
}
return [
'authenticated' => true,
'identifier' => $token->getUserIdentifier(),
'roles' => $token->getRoleNames(),
];
}
}
// Output for a request authenticated as [email protected] holding ROLE_USER:
// ['authenticated' => true, 'identifier' => '[email protected]', 'roles' => ['ROLE_USER']]
//
// Output for a request to a path behind a firewall with security: false:
// ['authenticated' => false, 'identifier' => null, 'roles' => []]When to use it
- Decide whether /admin deserves its own firewall or just an access_control rule.
- Work out why a request reaching your controller has no user object attached.
- Serve session-based login for the website and token authentication for /api in one application.
- Audit which URL paths are running with authentication switched off.
More examples
Three firewalls in one application
The order is the whole point. The dev firewall is first because its pattern is narrow and it must win for profiler URLs. The api firewall is stateless, so nothing is stored in a session and every request must carry its own credentials. The main firewall has no pattern at all; moving it above api would swallow every /api request and the api block would never run. Reach for this layout as soon as one application serves both HTML pages and a token-authenticated endpoint.
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
# Authentication off for the profiler and the web debug toolbar.
dev:
pattern: ^/(_profiler|_wdt)/
security: false
# Token in a header, no session written.
api:
pattern: ^/api
stateless: true
provider: app_user_provider
# No pattern, so this matches everything left. It must come last.
main:
lazy: true
provider: app_user_provider
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/login, roles: PUBLIC_ACCESS }A firewall chosen by something other than the path
The pattern, host and methods keys are regular expressions and lists. When the decision needs real logic, implement RequestMatcherInterface instead and name the service under request_matcher. The single method takes the Request and returns a bool. This is how you give office traffic a different authentication scheme from the public internet, or route a legacy subdomain to its own firewall. Behind a load balancer this only works once trusted proxies are configured, because getClientIp() otherwise reports the proxy.
<?php
// src/Security/InternalNetworkMatcher.php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
final class InternalNetworkMatcher implements RequestMatcherInterface
{
/**
* @param array<int, string> $trustedRanges CIDR ranges, injected from configuration
*/
public function __construct(private readonly array $trustedRanges)
{
}
public function matches(Request $request): bool
{
$clientIp = $request->getClientIp();
if (null === $clientIp) {
return false;
}
return IpUtils::checkIp($clientIp, $this->trustedRanges);
}
}
// Then in config/packages/security.yaml:
//
// firewalls:
// internal:
// request_matcher: App\Security\InternalNetworkMatcher
// provider: app_user_providerReading the user inside a controller
The #[CurrentUser] attribute pulls the user object out of the token and passes it to the controller as an argument, which reads better and tests more easily than calling a getter on the parent class. GET /account returns 200 with the account page for an authenticated visitor, and 302 to /login for anyone else. Type the argument as nullable whenever the route is reachable without authentication; a non-nullable type on an unprotected route produces a hard error instead of a redirect.
<?php
// src/Controller/AccountController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
final class AccountController extends AbstractController
{
// The nullable type matters: a visitor with no token gets null here.
#[Route('/account', name: 'app_account', methods: ['GET'])]
public function show(#[CurrentUser] ?UserInterface $user): Response
{
if (null === $user) {
return $this->redirectToRoute('app_login');
}
return $this->render('account/show.html.twig', [
'identifier' => $user->getUserIdentifier(),
]);
}
}
Discussion