Laravel Fortify - making 2fa required
By default Fortify makes 2FA optional
This article details how to make it required - using email by default with TOTP optional.
This assumes the Breeze → Fortify migration is done: feature flag on, trait on User, two-factor columns migrated.
0 . Make sure domains match
I’m using a multisite setup and need to ensure the public and auth route handlers dont overlap
in routes/web.php I added
$adminDomain = preg_quote((string) config('app.admin_domain'), '/');
$publicSiteDomain = "(?!{$adminDomain}$)[a-z0-9.-]+";
Route::domain('{site:domain}')->where(['site' => $publicSiteDomain])->group(function () {
and in phpunit.xml I make sure the default domain is defined
<env name="APP_ENV" value="testing"/>
<env name="ADMIN_DOMAIN" value="admin.webinar.test"/>
Create the Email Notification
app/Notifications/EmailTwoFactorLoginNotification.php
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EmailTwoFactorLoginNotification extends Notification
{
use Queueable;
public function __construct(
protected string $url,
protected ?string $ipAddress = null,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject('Confirm your login to '.config('app.name'))
->line('We received a login attempt for your account.')
->line('Click the button below to complete your sign in. The link expires in 15 minutes and can only be used once.');
if ($this->ipAddress) {
$message->line('Request IP address: '.$this->ipAddress);
}
return $message
->action('Complete sign in', $this->url)
->line('If you did not attempt to sign in, please tell an administrator.');
}
}
1. Always challenge after a valid password
Override RedirectIfTwoFactorAuthenticatable
Create app/actions/Fortify/RedirectIfTwoFactorAuthenticatable.php
Note that this references an email action we will create later
namespace App\Actions\Fortify;
use App\Services\EmailTwoFactorAuthentication;
use Illuminate\Contracts\Auth\StatefulGuard;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable as BaseAction;
use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged;
use Laravel\Fortify\LoginRateLimiter;
class RedirectIfTwoFactorAuthenticatable extends BaseAction
{
public function __construct(
StatefulGuard $guard,
LoginRateLimiter $limiter,
protected EmailTwoFactorAuthentication $emailTwoFactor,
) {
parent::__construct($guard, $limiter);
}
public function handle($request, $next)
{
$user = $this->validateCredentials($request);
return $this->twoFactorChallengeResponse($request, $user);
}
protected function twoFactorChallengeResponse($request, $user)
{
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => $request->boolean('remember'),
]);
if (! $user->usesTotp()) {
$this->emailTwoFactor->sendLoginLink($user, $request->ip());
}
TwoFactorAuthenticationChallenged::dispatch($user);
return $request->wantsJson()
? response()->json(['two_factor' => true])
: redirect()->route('two-factor.login');
}
}
2. Register the action
// FortifyServiceProvider::boot()
Fortify::redirectUserForTwoFactorAuthenticationUsing(
\App\Actions\Fortify\RedirectIfTwoFactorAuthenticatable::class
);
Fortify::twoFactorChallengeView(function (Request $request) {
$user = User::find($request->session()->get('login.id'));
return Inertia::render('Auth/TwoFactorChallenge', [
'usesTotp' => (bool) $user?->usesTotp(),
'status' => $request->session()->get('status'),
]);
});
Fortify::twoFactorChallengeView(function (Request $request) {
$user = User::find($request->session()->get('login.id'));
return Inertia::render('Auth/TwoFactorChallenge', [
'usesTotp' => (bool)$user?->usesTotp(),
'status' => $request->session()->get('status'),
]);
});
3. Helper on User
public function usesTotp(): bool
{
return ! is_null($this->two_factor_secret)
&& ! is_null($this->two_factor_confirmed_at);
}
4. Email one-time link (default second factor)
Cache a hashed token, send a signed URL. The link is useless without the login.id session cookie from the same browser.
namespace App\Services;
use App\Models\User;
use App\Notifications\EmailTwoFactorLoginNotification;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
class EmailTwoFactorAuthentication
{
const LINK_LIFETIME_MINUTES = 15;
public function sendLoginLink(User $user, ?string $ipAddress = null): void
{
$token = Str::random(64);
Cache::put(
'two_factor_email:'.$user->getKey(),
hash('sha256', $token),
now()->addMinutes(self::LINK_LIFETIME_MINUTES)
);
$url = URL::temporarySignedRoute(
'two-factor.email.verify',
now()->addMinutes(self::LINK_LIFETIME_MINUTES),
['user' => $user->getKey(), 'token' => $token]
);
$user->notify(new EmailTwoFactorLoginNotification($url, $ipAddress));
}
public function verify(User $user, string $token): bool
{
$key = 'two_factor_email:'.$user->getKey();
$cached = Cache::get($key);
if (! $cached || ! hash_equals($cached, hash('sha256', $token))) {
return false;
}
Cache::forget($key);
return true;
}
}
5. Complete login from the link
Create routes/auth.php.
<?php
use App\Http\Controllers\Auth\EmailTwoFactorChallengeController;
use Illuminate\Support\Facades\Route;
/*
| Login, logout, password reset/confirm/update and TOTP two-factor routes
| are registered by Laravel Fortify (see config/fortify.php ). Only the email-link second factor
| routes live here.
|
*/
Route::middleware('guest')->group(function () {
Route::get('two-factor-email/{user}/{token}', [EmailTwoFactorChallengeController::class, 'verify'])
->middleware(['signed', 'throttle:two-factor'])
->name('two-factor.email.verify');
Route::post('two-factor-email/resend', [EmailTwoFactorChallengeController::class, 'resend'])
->middleware('throttle:two-factor')
->name('two-factor.email.resend');
});
Route::middleware('guest')->group(function () {
Route::get('two-factor-email/{user}/{token}', [EmailTwoFactorChallengeController::class, 'verify'])
->middleware(['signed', 'throttle:two-factor'])
->name('two-factor.email.verify');
Route::post('two-factor-email/resend', [EmailTwoFactorChallengeController::class, 'resend'])
->middleware('throttle:two-factor')
->name('two-factor.email.resend');
});
app/Http/Controllers/EmailTwoFactorChallengeController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use App\Services\EmailTwoFactorAuthentication;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
class EmailTwoFactorChallengeController extends Controller
{
public function __construct(
protected EmailTwoFactorAuthentication $emailTwoFactor,
)
{
}
/**
* Complete login via the emailed one-time link.
*/
public function verify(Request $request, User $user, string $token): RedirectResponse
{
// The link only works in the browser that started the login,
// so a stolen link is useless without the session cookie too.
if ((int)$request->session()->get('login.id') !== $user->getKey()) {
return redirect()->route('login')
->withErrors(['email' => 'This sign-in link was opened in a different browser. Please log in again and open the link on the same device.']);
}
if (!$this->emailTwoFactor->verify($user, $token)) {
return redirect()->route('two-factor.login')
->withErrors(['link' => 'This sign-in link is invalid, expired, or has already been used.']);
}
event(new ValidTwoFactorAuthenticationCodeProvided($user));
$remember = $request->session()->pull('login.remember', false);
$request->session()->forget('login.id');
Auth::guard('web')->login($user, $remember);
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Resend the one-time link to the challenged user.
*/
public function resend(Request $request): RedirectResponse
{
$user = User::find($request->session()->get('login.id'));
if (!$user) {
return redirect()->route('login');
}
if (!$user->usesTotp()) {
$this->emailTwoFactor->sendLoginLink($user, $request->ip());
}
return back()->with('status', 'two-factor-email-sent');
}
}
Fire ValidTwoFactorAuthenticationCodeProvided so Fortify’s listeners (session flags, etc.) still run.
6. Challenge page
Show the TOTP form or “check your email” based on usesTotp.
resources/js/Pages/Auth/TwoFactorChallenge.jsx
/* global route */
import React from 'react';
import Alert from 'react-bootstrap/Alert';
import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react';
export default function TwoFactorChallenge({ usesTotp, status, errors }) {
const { data, setData, post, processing } = useForm({
code: '',
recovery_code: '',
});
const resendForm = useForm({});
const submit = (e) => {
e.preventDefault();
post(route('two-factor.login'));
};
const resend = (e) => {
e.preventDefault();
resendForm.post(route('two-factor.email.resend'));
};
return (
<GuestLayout>
<Head title="Two-factor authentication" />
<h2 className="mt-5">Two-factor authentication</h2>
{status === 'two-factor-email-sent' &&
<Alert variant="success">A new sign-in link has been sent to your email address.</Alert>
}
{errors.link &&
<Alert variant="danger">{errors.link}</Alert>
}
{usesTotp ? (
<Form onSubmit={submit} className="w-100">
<p>Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
<p>
<Form.Group controlId="code">
<Form.Label>Authentication code</Form.Label>
<Form.Control
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={data.code}
isFocused={true}
onChange={(e) => setData('code', e.target.value)}
/>
{errors.code &&
<Alert variant="danger">{errors.code}</Alert>
}
</Form.Group>
</p>
<p>
<Form.Group controlId="recovery_code">
<Form.Label>Recovery code</Form.Label>
<Form.Control
type="text"
value={data.recovery_code}
onChange={(e) => setData('recovery_code', e.target.value)}
/>
{errors.recovery_code &&
<Alert variant="danger">{errors.recovery_code}</Alert>
}
</Form.Group>
</p>
<Button className="d-block mx-auto my-3" type="submit" disabled={processing}>
Continue
</Button>
</Form>
) : (
<>
<Alert variant="info">
We have emailed you a one-time sign-in link. Click the link in that email
(in this browser) to finish logging in.
</Alert>
<p>The link expires in 15 minutes and can only be used once.</p>
<Form onSubmit={resend}>
<Button variant="outline-primary" type="submit" disabled={resendForm.processing}>
Resend sign-in link
</Button>
</Form>
</>
)}
</GuestLayout>
);
}
TOTP POST still goes to Fortify’s two-factor.login. Only the email path is custom.
7. Assert it in tests
use Illuminate\Support\Facades\Notification;
public function test_valid_password_leads_to_two_factor_challenge_not_login(): void
{
Notification::fake();
$user = User::factory()->create();
$response = $this->post('/', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertGuest();
$response->assertRedirect(route('two-factor.login'));
Notification::assertSentTo($user, EmailTwoFactorLoginNotification::class);
}
public function test_totp_users_are_challenged_without_email(): void
{
Notification::fake();
$user = User::factory()->create();
$user->forceFill([
'two_factor_secret' => encrypt('secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
'two_factor_confirmed_at' => now(),
])->save();
$this->post('/', ['email' => $user->email, 'password' => 'password']);
$this->assertGuest();
Notification::assertNothingSent();
}
A correct password must never authenticate. TOTP users must not get the email.
TOTP-only alternative (no email)
If you do not want the email fallback, still override the action to always challenge, and add a gate after login that forces /user/two-factor-authentication until two_factor_confirmed_at is set. Otherwise first-time users cannot complete the TOTP form.
Fortify still will not require 2FA for you. The action override is the requirement; the email link (or a setup gate) is how users who have no authenticator yet get through.