symfony/security-bundle
SecurityBundle tightly integrates Symfony's Security component into the full-stack framework, giving you configuration-driven authentication, authorization, firewalls, access control and user providers without wiring the services by hand.
Installation:
composer require symfony/security-bundle
Add to config/bundles.php:
return [
// ...
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
];
Basic Configuration (config/packages/security.yaml):
security:
enable_authenticator_manager: true
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
firewalls:
main:
lazy: true
provider: app_user_provider
form_login: ~
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
First Use Case:
User entity with UserInterface and PasswordAuthenticatedUserInterface.use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
public function secureAction(): Response
{
return new Response('Secure content');
}
config/packages/security.yaml: Core configuration.src/Security/LoginFormAuthenticator.php: Custom authenticator example.src/Entity/User.php: User entity with roles/credentials.[SECURITY] release). If you are on 5.4 LTS, pin symfony/security-bundle:^5.4.54 — the patch is a drop-in upgrade, no config changes required.# config/packages/security.yaml
firewalls:
main:
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
app_login with AuthenticationUtils to handle login errors:
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
public function login(AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'error' => $error,
'last_username' => $lastUsername,
]);
}
Changed in v5.4.54: login throttling uses a lock by default instead of cache-based counters.
# config/packages/security.yaml
firewalls:
main:
login_throttling:
max_attempts: 5
interval: '15 minutes'
# lock_factory: lock.default.factory # optional override
flock-style lock is fine.# config/packages/lock.yaml
framework:
lock:
default: '%env(LOCK_DSN)%' # redis://..., semaphore://..., etc.
login_throttling: '%env(LOCK_DSN)%'
/tmp), point the lock at a writable store or it will fail at login time.Extend AbstractAuthenticator for OAuth, API tokens, or custom logic:
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class CustomAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return $request->headers->has('X-API-TOKEN');
}
public function authenticate(Request $request): Passport
{
$token = $request->headers->get('X-API-TOKEN');
return new Passport(new ApiKeyAuthenticator($token));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse('/dashboard');
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessage()], 401);
}
}
Register in security.yaml:
firewalls:
api:
pattern: ^/api
stateless: true
custom_authenticator: app.custom_authenticator
# config/packages/security.yaml
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
#[IsGranted('ROLE_ADMIN')]
public function adminDashboard(): Response
{
return new Response('Admin Panel');
}
Create a voter for complex logic (e.g., "can edit own profile"):
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProfileVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'EDIT_PROFILE' && $subject instanceof User;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $user === $subject || $user->hasRole('ROLE_ADMIN');
}
}
Register in security.yaml:
security:
access_decision_manager:
strategy: affirmative
voters:
App\Security\ProfileVoter: ~
firewalls:
main:
lazy: true # Loads only when accessed
provider: app_user_provider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~
main:
pattern: ^/
form_login: ~
Changed in v5.4.54: redirections are now restricted to the current host when sessions are disabled.
If you run stateless firewalls (API tokens, JWT) that redirect after login/registration, cross-host targets are now dropped. Keep redirect targets host-relative (or build them with UrlGeneratorInterface::ABSOLUTE_PATH) rather than echoing a user-supplied redirect parameter.
// Safe: stays on the current host
return new RedirectResponse($this->generateUrl('dashboard'));
// Previously worked on stateless firewalls, now blocked by design
// return new RedirectResponse('https://other.example.com/...');
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api
stateless: true
jwt: ~
providers:
api_user_provider:
entity:
class: App\Entity\User
property: apiToken
lexik/jwt-authentication-bundle for JWT generation/validation.# config/packages/security.yaml
security:
firewalls:
main:
oauth:
resource_owners:
google: ~
login_path: /connect/google
use_forward: false
failure_path: /login
use_referer or a post-login target_path that crosses hosts.Invalid CSRF token on form submission.enable_csrf: true in form_login and the form includes {{ form_row(form._token) }}.php bin/console debug:firewall for active firewalls./contact).lazy: true carefully:
firewalls:
main:
lazy: true
remember_me:
secret: '%kernel.secret%'
lifetime: 86400
path: /login
ROLE_ADMIN not inheriting ROLE_USER permissions.security.yaml:
security:
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_USER: ROLE_GUEST
framework.lock with a shared backend (Redis, PDO, etc.) and verify with a login failure loop in staging.429), not the message text.php bin/console debug:security:role-hierarchy
php bin/console debug:firewall
php bin/console debug:voter App\Entity\Post 1 EDIT
Enable debug logging in config/packages/monolog.yaml:
monolog:
handlers:
security:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.security.log"
level: debug
channels: ["security"]
hide_user_not_found (Symfony 8+).
authentication_failure_path / custom AuthenticationFailureHandler.security:
oidc:
trusted_hosts:
- example.com
php bin/console cache:pool:clear cache.security_oauth2
| Area | What to check |
|---|---|
| Stateless firewalls | Any redirect to an absolute cross-host URL is now blocked when stateless: true / sessions disabled. |
| Login throttling | framework.lock is reachable and shared across nodes. |
| Tests | Throttling + redirect assertions; run your auth feature tests after the bump. |
| Composer | composer update symfony/security-bundle --with-dependencies and re-run the firewall debug command. |
# config/packages/security.yaml
providers:
custom_provider:
id: App\Security\CustomUserProvider
UserProviderInterface:
class CustomUserProvider implements UserProviderInterface
{
public function loadUserByIdentifier(string $identifier): UserInterface
{
// ...
}
public function refreshUser(UserInterface $user): UserInterface
{
// ...
}
public function supportsClass(string $class): bool
{
return $userClass === $class || is_subclass_of($class, $userClass);
}
}
supportsClass() must return true for subclasses or you get "user not refreshed" errors on session reload — a frequent false lead when debugging unexpected logouts.How can I help you explore Laravel packages today?