Search by

wexample / symfony-user

weeger

User accounts and authentication for Symfony

Package info

github.com/wexample/symfony-user

pkg:composer/wexample/symfony-user

Statistics

Installs: 340

Dependents: 9

Suggesters: 0

Stars: 0

Open Issues: 0

14.0.4 2026-10-22 17:17 UTC

README

Version: 14.0.4

User accounts and authentication for Symfony

Table of Contents

Installation

Bundles

// config/bundles.php
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
Wexample\SymfonySecurity\WexampleSymfonySecurityBundle::class => ['all' => true],
Wexample\SymfonyUser\WexampleSymfonyUserBundle::class => ['all' => true],

WexampleSymfonySecurityBundle masks the package's secrets in the logs: without it, the container refuses to build.

The screens — login, second factor, password, terms, authenticator app — are symfony-user-ds's: install it with Wexample\SymfonyUserDs\WexampleSymfonyUserDsBundle. An application drawing its own serves pages under the names of Routing\UserRoute: the package redirects to them. Its own endpoints — /login/link, /logout, the reset and activation links, the code resend, new backup codes — stay here.

The scheb recipe also adds config/routes/scheb_2fa.yaml: delete it, the code form is served on /login/2fa.

The user entity

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User extends AbstractUser
{
    use UserWithNameTrait; // optional: first and last name, display name
}

class UserRepository extends AbstractUserRepository
{
    public static function getEntityClassName(): string
    {
        return User::class;
    }
}

AbstractUser is not a mapped superclass: its columns land in the application table. Generate a migration after extending it.

Security

# config/packages/security.yaml
security:
    providers:
        users:
            entity:
                class: App\Entity\User   # no `property`: email or username
    firewalls:
        main:
            lazy: true
            provider: users
            custom_authenticators:
                - Wexample\SymfonyUser\Security\Authenticator\LoginFormAuthenticator
            entry_point: Wexample\SymfonyUser\Security\Authenticator\LoginFormAuthenticator
            user_checker: Wexample\SymfonyUser\Security\UserChecker
            logout:
                path: user_security_logout
            login_throttling: ~
            remember_me:
                secret: '%kernel.secret%'
                signature_properties: [password]
            login_link:
                check_route: user_security_login_link
                signature_properties: [password, dateLastLogin]
                lifetime: 600
                success_handler: Wexample\SymfonyUser\Security\Handler\LoginLinkSuccessHandler
            two_factor:
                auth_form_path: user_security_two_factor
                prepare_on_login: true
                check_path: /_forms/submit/form-two_factor_code_form
                auth_code_parameter_name: two_factor_code_form[code]
                trusted_parameter_name: two_factor_code_form[trusted]
                success_handler: Wexample\SymfonyUser\Security\Handler\TwoFactorResponseHandler
                failure_handler: Wexample\SymfonyUser\Security\Handler\TwoFactorResponseHandler
                authentication_required_handler: Wexample\SymfonyUser\Security\Handler\TwoFactorResponseHandler
                enable_csrf: true
                csrf_parameter: two_factor_code_form[_token]
                csrf_token_id: submit   # the form CSRF token id of the app
    access_control:
        - { path: ^/login/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
        # What serves the pages rather than being one: symfony-loader's
        # components rendered on demand, symfony-design-system's ui state.
        - { path: ^/_(system|ui-state)/, roles: PUBLIC_ACCESS }
# config/packages/scheb_2fa.yaml
scheb_two_factor:
    security_tokens:
        - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
    trusted_device:
        enabled: true
        lifetime: 5184000
    # Authenticator apps, and their backup codes: /account/authenticator
    totp:
        enabled: true
        issuer: 'My application'
        leeway: 1
    backup_codes:
        enabled: true
  • APP_SECRET must be 32 bytes at least: the trusted device cookie is a JWT signed with it. The authenticator secrets are encrypted with a key derived from it too: changing it makes users set their app up again.
  • While a login waits for its code, scheb only lets through the paths access_control opens to PUBLIC_ACCESS or IS_AUTHENTICATED_2FA_IN_PROGRESS, and sends every other request to the code's page. That includes the /_system/ and /_ui-state/ calls the page's scripts make — the debug toolbar, a component loaded later —: left out, they receive the code's page instead of their JSON, and the browser reports Unexpected token '<'. They are already open to anyone without a rule, so naming them PUBLIC_ACCESS opens nothing more. The rule has to be written by the application: Symfony refuses access_control from more than one file, so no bundle can add it.
  • Without a second factor, drop the two_factor key; to ask it on every login, set trusted_device.enabled: false — the code form then drops its "trust this device" switch.

Mails

Links and codes are sent through symfony-mail's MailSenderService, which requires WexampleSymfonyMailBundle to be registered. They are framed by the application's mail layout (wexample_symfony_mail.layout), their text part keeps the link's address, and they are written in the account's language (AbstractUser::getLocale()), or the default locale. The sender comes from the mailer configuration:

framework:
    mailer:
        headers:
            From: 'no-reply@example.com'

To deliver them another way, alias Wexample\SymfonyUser\Interface\SecurityMessageSenderInterface to your own service. The templates are @WexampleSymfonyUserBundle/mails/<type>.html.twig — magic_link, password_reset, account_activation, two_factor_code —, overridden under templates/bundles/WexampleSymfonyUserBundle/mails/.

Link mails leave through Messenger, as Wexample\SymfonyUser\Message\SendSecurityMessage: the message names the account and the kind of link, and the handler builds the link when it sends, so neither the transport nor the failed queue ever holds one. Route it to an asynchronous transport, so a request never waits on the mail provider:

framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy: { max_retries: 3, multiplier: 2 }
            failed: 'doctrine://default?queue_name=failed'
        routing:
            Wexample\SymfonyUser\Message\SendSecurityMessage: async
    router:
        default_uri: '%env(APP_URL)%'   # the worker has no request: the links take their host from here

Unrouted, the mails are sent within the request. Codes of the second factor are always sent at once. The mails are handed to the mailer transport directly, never to MailerInterface: with SendEmailMessage routed to a transport, as the Flex recipe does, the rendered mail would be queued with its link.

In development, keep the mails on the machine: MAILER_DSN=null://null drops them (the profiler still shows them), a Mailpit container (MAILER_DSN=smtp://mailpit:1025) shows them in its web interface.

Bundle options

# config/packages/wexample_symfony_user.yaml
wexample_symfony_user:
    password_reset: token          # or magic_link
    reveal_account_status: false   # true: a disabled or locked account is told so, once its password is right
    terms:
        version: ~                 # set: every signed-in user accepts this version before anything else; changing it asks everyone again
        text_route: ~              # the route of the page holding the text, owned by the application
    remember_me: true              # false: no "stay signed in" on the login form — for a firewall without remember_me
    magic_link_login: true         # false: no magic link form on the login page; MagicLinkService still sends the application's own links
    session:
        idle_lifetime: 0           # seconds a signed-in session survives unused; 0, never expires one
    password:
        refuse_leaked: false       # true: refuse a password found in a breach — needs symfony/http-client, and an outbound call per password set
    activation:
        link_lifetime: 604800      # seconds the link of an activation mail works
    impersonation:
        targets: administered      # or any: who may be impersonated; who may impersonate is the firewall's switch_user
        list_threshold: 50         # up to this many, the page lists them all; above, it searches
        require_intent: true       # a switch must come from the impersonation form, not a bare ?_switch_user= link
    post_login:
        routes: {}                 # ROLE_X: route, in order — where a user lands when the page they came from is not theirs to open
        default_route: ~           # for a user holding none of them; unset, the home page
    request_limit:                 # reset and magic link mails, per hour; past it, the same answer and no mail
        per_identifier: 3
        per_ip: 20
    administration:                # the rules of AccountAdministrationService and AccountRulesService
        page_role: ~               # the role opening the administration screens of symfony-user-ds; unset, they answer 404 for everyone
        protected_roles: []        # never left without an active holder
        manages: {}                # ROLE_X: [ROLE_A] — the roles ROLE_X administers, with no right over their pages; unset, the roles reached
        role_email_domains: {}     # ROLE_X: [example.com] — held only by an address of these domains
        exclusive_roles: {}        # ROLE_X: [ROLE_A, ROLE_B] — never given together
    two_factor:
        pending_lifetime: 600      # seconds a correct password waits for its second factor; past it, back to the password
        required: false            # true: every sign-in — magic link, login after a reset — asks a second factor, no account exempt
        app_required_roles: []     # roles that must use an authenticator app: held on its setup page until it is there, never allowed to turn it off

Off — the default —, unknown address, wrong password, disabled and locked account all get the same response; an unknown address also costs a password check, so the timing tells nothing either. That shield needs the password hasher configured on PasswordAuthenticatedUserInterface, as the Symfony recipe does.

symfony-user is a Symfony bundle for user accounts and their authentication. An application extends AbstractUser for its own User entity and gets: a password login that works as a page, in an ajax modal or embedded in any page (a tunnel step); login throttling; a second factor sent by email on devices not trusted yet, built on scheb/2fa-bundle; magic links; password change and reset, by signed link or by magic link; and role helpers (users by role, roles above a role, roles an editor may grant). Forms are ordinary symfony-forms ajax forms rendered with the design system; the security side is plain Symfony Security, so throttling, remember-me and the user checker keep working as documented by Symfony.

The showcase lives in symfony-user-demo, served on /user/ by the design-system app, with public test accounts and a mailbox standing in for real emails.