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, which is how Fortify's shipped config
registers the feature, a stored secret alone counts, exactly as it does for the password form.
- The token is consumed.
- Fortify's
login.idsession key is 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.
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 — which makes it the precise signal for an audit log.
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.