Form Login and Logout
Wiring form_login to a controller and template you own, with a CSRF token, a remember-me cookie and a logout route.
form_login: { login_path: app_login, check_path: app_login, enable_csrf: true }form_login is an authenticator that ships with the framework. It watches one URL for a POST, reads two fields out of the request body, and checks them against the user provider and the password hasher. You write the page; it handles the submission.
Two paths, usually one route
login_path is where unauthenticated visitors are sent. check_path is where the form posts. Both normally point at the same route, so one controller renders the form on GET while the firewall intercepts the POST before that controller ever runs. The field names default to _username and _password.
Your controller injects AuthenticationUtils to read the last submitted identifier and the last authentication error, which is how a failed attempt redisplays the form with a message.
CSRF and remember-me
Set enable_csrf: true and add a hidden field holding csrf_token('authenticate'). The token id in the template must match the firewall's csrf_token_id, which defaults to authenticate. Add the remember_me key and a checkbox named _remember_me to issue a signed cookie that signs the user back in on a later visit.
Logging out
Add the logout key with a path and a target. The route must exist in your routing, but its controller never runs, because the firewall handles that request first. Keep login_path reachable without authentication, with roles: PUBLIC_ACCESS or no rule at all, or the redirect to the login page is itself protected and the browser loops.
Example
<?php
// src/Controller/SecurityController.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\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
// Attributes only. Symfony 7.0 removed docblock annotation support, so a
// route written as @Route in a comment is simply not registered.
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// Only reached on GET: the firewall takes the POST to check_path first.
return $this->render('security/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): never
{
// Never executed. The logout listener on this firewall answers first.
// The route still has to exist, or /logout 404s before security runs.
throw new \LogicException('Handled by the logout key in security.yaml.');
}
}
// GET /login returns 200 with the login page.
// POST /login with a wrong password returns 302 back to /login, and
// getLastAuthenticationError() then holds a BadCredentialsException.
// POST /login with the right password returns 302 to the page the visitor
// was originally trying to reach, or to default_target_path.When to use it
- Add a login page with your own markup, messages and layout.
- Keep a visitor signed in for a week behind an opt-in checkbox.
- Put a working sign-out link in the layout without writing any controller logic.
- Rate-limit repeated failed logins against the same email address.
More examples
The firewall configuration
Everything the login flow needs sits in one firewall block. default_target_path is where a user lands when they visited the login page directly rather than being bounced off a protected URL. login_throttling is worth adding on the first day: it rate-limits repeated failures, which is the cheapest defence against credential stuffing. enable_csrf under logout means the logout link must carry a token, so a third-party page cannot sign your users out with an image tag.
# config/packages/security.yaml
security:
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login # where unauthenticated visitors go
check_path: app_login # where the form posts
enable_csrf: true
# csrf_token_id: authenticate # this is the default
# username_parameter: _username # these are the defaults
# password_parameter: _password
default_target_path: app_dashboard
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # one week, in seconds
secure: true # only send the cookie over HTTPS
samesite: lax
logout:
path: app_logout # a route name, or a literal path
target: app_home
enable_csrf: true
login_throttling:
max_attempts: 5
interval: '15 minutes'
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/dashboard, roles: ROLE_USER }The login template
This is a plain HTML form rather than a Symfony form object, because the firewall consumes the POST before any form handling code could run. The input names are exactly what form_login looks for in the request body. Note what is absent: no |raw on the error message, so a message carrying submitted data stays escaped. The _remember_me checkbox is opt-in by design; setting always_remember_me: true on the firewall issues the cookie whether the checkbox exists or not.
{# templates/security/login.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
{% if error %}
{# messageKey is a translation key and messageData its placeholders.
No |raw here: the message is escaped like everything else. #}
<p>{{ error.messageKey|trans(error.messageData, 'security') }}</p>
{% endif %}
<form action="{{ path('app_login') }}" method="post">
<label for="username">Email</label>
<input type="email" id="username" name="_username"
value="{{ last_username }}" autocomplete="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="_password"
autocomplete="current-password" required>
<label>
<input type="checkbox" name="_remember_me"> Keep me signed in
</label>
{# The id must match csrf_token_id on the firewall. #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">Sign in</button>
</form>
{% endblock %}Two ways to write the logout link
Enabling the logout key registers a route named _logout_<firewall>, so logout_path('main') and path('_logout_main') are the same thing written two ways. Recognising both matters when reading someone else's templates, and the longhand name is what shows up in debug:router output. Prefer the helper: when enable_csrf is on it appends the CSRF token to the URL, while a hand-built path() link does not and the logout is then rejected.
{# templates/base.html.twig #}
{# Shorthand: the helper, given the firewall name. #}
<a href="{{ logout_path('main') }}">Sign out</a>
{# Longhand: the route the firewall registered for you, by name. #}
<a href="{{ path('_logout_main') }}">Sign out</a>
{# Those two produce the same URL. The absolute pair works the same way: #}
<a href="{{ logout_url('main') }}">Sign out</a>
<a href="{{ url('_logout_main') }}">Sign out</a>
{# With a single firewall the argument can be dropped, since the helper
defaults to the firewall handling the current request. #}
<a href="{{ logout_path() }}">Sign out</a>
Discussion