Magic links in Symfony: passwordless login with login_link, without the usual holes

Symfony ships passwordless sign-in in the security component. Here is a production login_link setup: signature invalidation, enumeration-proof requests, rate limiting, and the 2FA interplay nobody writes about.

Magic links — email me a link, click, signed in — are the lowest-friction login you can offer, and Symfony ships them natively in the security component as login_link. No bundle, no third-party auth service. What the one-paragraph tutorials skip is everything around the happy path: link invalidation, user enumeration, rate limiting, email scanners that click your links before the user does, and what happens when the account has 2FA enabled. This is the setup ShipAnvil ships, edge by edge.

The firewall wiring

One block under your firewall:

# config/packages/security.yaml
firewalls:
    main:
        # Passwordless "magic link" sign-in (Symfony LoginLink).
        # Hashing the password into the signature invalidates every
        # outstanding link when the password changes.
        login_link:
            check_route: auth_login_link_check
            signature_properties: [id, email, password]
            lifetime: 600

Three decisions live in these five lines.

signature_properties is your invalidation mechanism. The link's hash covers the listed user properties; if any of them changes, every outstanding link dies instantly. Putting the (hashed) password in the list means "I changed my password" also means "all my unused sign-in links are void" — which is exactly what a user who just cleaned up after a phishing scare expects. If your users can change their email, that property is already doing the same job.

lifetime: 600 is the default, made explicit. Ten minutes is long enough for a slow inbox, short enough that a link lying in a forwarded email thread next week is dead. This is a line you want visible in code review, not implicit.

The check route is a dead route. The firewall authenticator intercepts it before your controller runs:

#[Route('/login/link/check', name: 'auth_login_link_check')]
public function loginLinkCheck(): never
{
    throw new \LogicException('This route is intercepted by the firewall login-link authenticator.');
}

If that exception ever fires, your firewall config regressed — a surprisingly useful canary.

The request endpoint is where the security actually lives

Sending the link is your code, and it is the part attackers probe:

#[Route('/magic-link', name: 'auth_magic_link', methods: ['GET', 'POST'])]
public function magicLink(
    Request $request,
    UserRepository $userRepository,
    LoginLinkHandlerInterface $loginLinkHandler,
    MailerInterface $mailer,
    RateLimiterFactoryInterface $authMagicLinkLimiter,
): Response {
    if ($request->isMethod('POST')) {
        if (!$this->isCsrfTokenValid('magic-link', $request->getPayload()->getString('_token'))) {
            throw $this->createAccessDeniedException('Invalid CSRF token.');
        }

        if (!$authMagicLinkLimiter->create($request->getClientIp() ?? 'unknown')->consume()->isAccepted()) {
            $this->addFlash('error', 'Too many attempts — please wait a few minutes and try again.');

            return $this->render('auth/magic_link.html.twig', [], new Response(status: Response::HTTP_TOO_MANY_REQUESTS));
        }

        $user = $userRepository->findOneByEmail($request->getPayload()->getString('email'));

        // Only verified accounts may sign in (same rule as UserChecker),
        // and unknown addresses are silently ignored (no enumeration).
        if (null !== $user && $user->isVerified()) {
            $loginLink = $loginLinkHandler->createLoginLink($user, $request);

            $mailer->send((new TemplatedEmail())
                ->to(new Address($user->getEmail()))
                ->subject('Your sign-in link')
                ->htmlTemplate('email/login_link.html.twig')
                ->context([
                    'login_link_url' => $loginLink->getUrl(),
                    'expires_at' => $loginLink->getExpiresAt(),
                ]));
        }

        return $this->render('auth/magic_link_sent.html.twig');
    }

    return $this->render('auth/magic_link.html.twig');
}

Four rules, each earning its place:

  • No enumeration. Unknown email or unverified account, the response is the same "check your inbox" page. The form must not be an oracle for "does this address have an account here".
  • Verified accounts only — the same rule the UserChecker applies to password logins. A magic link that signs in an unverified address quietly becomes your email verification, except nobody designed it to be that.
  • Rate-limited per IP (5 requests per 15 minutes, sliding window). This endpoint sends email to any address typed into a form; without a limiter it is a free spam cannon pointed at your own domain reputation.
  • CSRF on the POST, like any state-changing route. It also keeps third-party sites from triggering emails to their victims through your form.

One thing the tutorials get wrong: links are not single-use

A signed login link is valid until it expires — Symfony does not burn it on first click. If your threat model needs single-use, the component has max_uses backed by a cache pool, but read the fine print before turning it on: corporate email scanners and link-preview bots follow GET links, and with max_uses: 1 they consume the link before the human does. The escape hatch is check_post_only: true (the check route then requires a POST, which scanners don't send) — at the cost of an interstitial "confirm sign-in" page.

ShipAnvil deliberately ships the simpler model: a 10-minute lifetime, signature invalidation on password change, and no link storage. Honest trade-off: a link can be clicked twice inside its window; it cannot survive a password reset, a changed email, or its own expiry. For a SaaS login (as opposed to, say, a banking second factor), that is the right default — and max_uses is one config line away when it isn't.

The 2FA interplay nobody writes about

If an account has TOTP enabled, a magic link must not skip the challenge — otherwise your "second factor" is optional at the attacker's choice of door. With scheb/2fa, coverage is decided by which security tokens trigger the challenge:

# config/packages/scheb_2fa.yaml
scheb_two_factor:
    security_tokens:
        # Form login and magic-link (login_link) sign-ins trigger the 2FA
        # challenge. Remember-me cookies are intentionally trusted.
        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
        - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

The login-link authenticator produces a PostAuthenticationToken; list it, and the link lands on the 2FA form, not the dashboard. Then prove it stays that way: ShipAnvil's functional suite requests a link for a 2FA-enabled user, follows it, and asserts the challenge page — because this is precisely the kind of guarantee a refactor silently breaks. (More on the 2FA edges in TOTP two-factor auth done properly.)

Test the doors, not the happy path

The three functional tests that matter, straight from the kit's suite: a verified user receives a link and it signs them in; an unknown email sends nothing but renders the same page; an unverified user receives nothing. Add the rate-limiter assertion on the wired service and the 2FA-challenge test above, and every property this article promised is enforced by CI instead of by hope.

Ship it wired

Magic links touch the firewall, the mailer, rate limiting, CSRF, email templates and 2FA at once — which is why most codebases either skip them or ship the tutorial version. ShipAnvil comes with this exact setup working out of the box — the controller, the themed email, the limiter config, and the functional tests for every edge above, next to password login, TOTP 2FA, teams and billing. Try the flow on the live demo, or see what's in the box.