Skip to main content

The two-factor handoff

When the Fortify bridge is active and a verified user has two-factor authentication enabled by Fortify's own verdict — hasEnabledTwoFactorAuthentication(), the same check Fortify's password login makes — the magic link is handed to the challenge. With Fortify's confirm option on, that verdict is a stored secret and a two_factor_confirmed_at, so a user mid-setup is never locked out; with confirm off, a stored secret alone counts, exactly as it does for the password form. Which one you have depends on the config: the one fortify:install publishes turns confirm on, while Fortify's own default, used until you publish one, registers the feature without options and so leaves it off.

  1. The token is consumed.
  2. Fortify's login.id and login.remember session keys are set and the request is redirected to Fortify's two-factor.login challenge — without logging the user in.
  3. The login completes inside Fortify only after the TOTP code passes.

login.remember carries the person's "Stay signed in" choice, so Fortify sets the remember cookie when the challenge passes. It is false unless remember.enabled is on and the person ticked the box.

Where the user lands after the code is Fortify's call as well: the intended URL, else fortify.redirects.login, else fortify.home. routes.redirect_to and routes.intended apply to a sign-in this package completes, and this one completes in Fortify.

There is no path that signs a two-factor user in without the second factor, and an end-to-end test runs the real Fortify challenge to keep it that way across Fortify upgrades.

The trade-off​

The token is already spent when the handoff happens, so if a user abandons the TOTP step they must request a fresh link. This is intentional — the link is single-use and the challenge is a separate, deliberate step.

Guard alignment​

When the handoff is enabled, email-magic-link.guard must resolve to the same provider as fortify.guard, because Fortify re-resolves the challenged user from its own guard's provider. With mismatched providers the challenge fails closed (the user cannot complete login) rather than logging anyone in. The default web guard satisfies this out of the box.

The same rule applies when a request selects another guard — see Multiple guards.

Configuration​

'fortify' => [
'mode' => env('EMAIL_MAGIC_LINK_FORTIFY', 'auto'),
'respect_two_factor' => true,
'challenge_route' => 'two-factor.login',
],
KeyEffect
mode: 'auto'Activate the bridge only when laravel/fortify is installed — the default
mode: trueThe same, and warn at boot if Fortify is missing
mode: falseNever activate, even when Fortify is installed
respect_two_factorRoute users with two-factor enabled through the challenge
challenge_routeThe Fortify challenge route name

fortify.respect_two_factor = false disables this handoff. This is a security downgrade: magic-link logins will skip two-factor for users who have it enabled. It emits a warning at boot.

What fires​

The bridge dispatches TwoFactorChallengeRequired when it hands a user to the challenge. MagicLinkAuthenticated fires only on a completed login, never for a handoff. The handed-off user's login completes inside Fortify, which fires Laravel's Login but not MagicLinkAuthenticated, so an audit log that should cover these sign-ins listens for TwoFactorChallengeRequired and Login as well.