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.
- The token is consumed.
- Fortify's
login.idandlogin.remembersession keys are set and the request is redirected to Fortify'stwo-factor.loginchallenge — without logging the user in. - 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',
],
| Key | Effect |
|---|---|
mode: 'auto' | Activate the bridge only when laravel/fortify is installed — the default |
mode: true | The same, and warn at boot if Fortify is missing |
mode: false | Never activate, even when Fortify is installed |
respect_two_factor | Route users with two-factor enabled through the challenge |
challenge_route | The 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.
Related
- The three configurations — standalone, bridge on, bridge off.
- Passphrase-gated links — a gate in front of this flow, not a substitute for it.
- The JSON contract — how an SPA client is told the challenge is required.