Skip to content
Bladewell
Guides

Two-factor authentication screens for Laravel Fortify

Fortify's two-factor setup, QR code, recovery codes and sign-in challenge as Blade screens, with a one-time code box and no JavaScript to write.

php artisan bladewell:add otp text-input password button toast

Uses OTP, Text input, Password, Button and Toast. New to Bladewell? Install it first.

What Fortify does, and what you build

Laravel Fortify does the hard part of two-factor authentication: it stores an encrypted secret per user, checks the six-digit codes, makes recovery codes, draws the QR code and asks for a code after the password. It ships no screens. This guide builds the three it needs: turning it on with a QR code, the recovery codes, and the sign-in challenge.

The code box is Bladewell's OTP: one box per digit, a pasted code fills them all, and it shows Fortify's errors under the boxes. There's no JavaScript to write, and every form posts to Fortify's own routes.

Turn the feature on

With Fortify installed, enable two-factor authentication with confirm on. It then stays off until the person types a code from their app, so a scan that went wrong can't lock them out.

config/fortify.php
// config/fortify.php
use Laravel\Fortify\Features;

return [
    // ...

    'features' => [
        // ...the features you already have, such as Features::registration(),
        Features::twoFactorAuthentication([
            // Off until the person types a code from their app: a scan that went
            // wrong can't lock them out.
            'confirm' => true,
            // Asks for their password before turning it on or off, or showing the
            // recovery codes.
            'confirmPassword' => true,
        ]),
    ],
];

Your user model keeps everything it has and gains Fortify's trait, which adds the QR code, the recovery codes and the columns behind them (Fortify's migration adds two_factor_secret, two_factor_recovery_codes and two_factor_confirmed_at).

app/Models/User.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;

// Your existing model: add Fortify's trait and two hidden columns, and keep the rest.
class User extends Authenticatable
{
    use HasFactory, Notifiable, TwoFactorAuthenticatable;

    // ... your $fillable, casts() and the rest, as they are.

    // The secret and the recovery codes are stored encrypted; never send them to
    // the browser in JSON.
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_secret',
        'two_factor_recovery_codes',
    ];
}

The settings page

One page covers every state: off, being set up, and on. Its route asks for the password first, like Fortify's own two-factor routes, because the page shows the secret.

routes/web.php
// routes/web.php
use App\Http\Controllers\TwoFactorSettingsController;

// password.confirm: the page shows the secret and the recovery codes, so ask for
// the password first, as Fortify's own two-factor routes do.
Route::get('/settings/two-factor', TwoFactorSettingsController::class)
    ->middleware(['auth', 'password.confirm'])
    ->name('settings.two-factor');

That password step is Fortify's, and it needs a screen: register it in your FortifyServiceProvider, shown with the sign-in challenge below, and give it a form. Without it, the settings page can't be reached.

resources/views/auth/confirm-password.blade.php
<form method="POST" action="{{ route('password.confirm.store') }}" class="grid max-w-sm gap-5">
    @csrf
    <p>Please confirm your password to continue.</p>
    <x-widget.password name="password" label="Password" autocomplete="current-password" required autofocus />
    <x-widget.button type="submit">Confirm</x-widget.button>
</form>

The controller works out the state and passes the view what it needs. Fortify tells the page what just happened with a status in the session, so the controller turns that into words for the toast (with <x-widget.toast /> once in your layout).

app/Http/Controllers/TwoFactorSettingsController.php
namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Laravel\Fortify\Fortify;

class TwoFactorSettingsController
{
    public function __invoke(Request $request): View
    {
        $user = $request->user();
        $enabled = $user->two_factor_secret !== null;
        $confirmed = $user->two_factor_confirmed_at !== null;
        $settingUp = $enabled && !$confirmed;

        // Fortify reports what it did as a status; the toast shows it in words.
        $message = [
            Fortify::TWO_FACTOR_AUTHENTICATION_CONFIRMED => 'Two-factor authentication is on.',
            Fortify::TWO_FACTOR_AUTHENTICATION_DISABLED => 'Two-factor authentication is off.',
            Fortify::RECOVERY_CODES_GENERATED => 'New recovery codes are ready. The old ones no longer work.',
        ][$request->session()->get('status')] ?? null;
        if ($message !== null) {
            $request->session()->now('success', $message);
        }

        return view('settings.two-factor', [
            'enabled' => $enabled,
            'confirmed' => $confirmed,
            // Only while setting up: once confirmed, the QR code and the key aren't
            // shown again.
            'qrCodeSvg' => $settingUp ? $user->twoFactorQrCodeSvg() : null,
            'setupKey' => $settingUp ? Fortify::currentEncrypter()->decrypt($user->two_factor_secret) : null,
            'recoveryCodes' => $confirmed ? $user->recoveryCodes() : [],
        ]);
    }
}

While it's being set up, the page shows the QR code, the same secret as text for anyone who can't scan, and the code box. Two details matter:

  • Fortify's QR code has no blank border of its own, and scanners need one. The white padding around it is that border, and it stays white in dark mode.
  • A wrong code comes back in Fortify's own error bag, so the code box reads it with bag="confirmTwoFactorAuthentication".
resources/views/settings/two-factor.blade.php
<section class="grid max-w-xl gap-6">
    <h2 class="text-xl font-semibold">Two-factor authentication</h2>

    @if (! $enabled)
        <p>Sign-ins will also ask for a code from an authenticator app on your phone, so a stolen password isn't enough.</p>
        <form method="POST" action="{{ route('two-factor.enable') }}">
            @csrf
            <x-widget.button type="submit">Turn on</x-widget.button>
        </form>
    @elseif (! $confirmed)
        <ol class="grid list-decimal gap-6 pl-5">
            <li class="grid gap-3">
                <p>Scan this code with your authenticator app (Google Authenticator, 1Password, Authy…).</p>
                {{-- Fortify's SVG, from the user's own secret. It has no margin:
                     the white padding is the border scanners need. --}}
                <div class="size-56 rounded-lg bg-white p-4 [&>svg]:size-full" role="img" aria-label="QR code for your authenticator app">
                    {!! $qrCodeSvg !!}
                </div>
            </li>
            <li class="grid gap-2">
                <p>Can't scan it? Enter this key in the app instead:</p>
                <code class="font-mono text-base break-all select-all">{{ $setupKey }}</code>
            </li>
            <li>
                <form method="POST" action="{{ route('two-factor.confirm') }}" class="grid gap-4">
                    @csrf
                    {{-- Fortify reports a wrong code in its own error bag. --}}
                    <x-widget.otp name="code" label="Then enter the 6-digit code it shows" bag="confirmTwoFactorAuthentication" group="3" autofocus />
                    <div class="flex gap-3">
                        <x-widget.button type="submit">Confirm</x-widget.button>
                        <x-widget.button type="submit" variant="neutral" form="cancel-two-factor">Cancel</x-widget.button>
                    </div>
                </form>
                <form id="cancel-two-factor" method="POST" action="{{ route('two-factor.disable') }}">
                    @csrf
                    @method('DELETE')
                </form>
            </li>
        </ol>
    @else
        <p>On. Sign-ins ask for a code from your authenticator app.</p>
        @include('settings.recovery-codes')
        <form method="POST" action="{{ route('two-factor.disable') }}">
            @csrf
            @method('DELETE')
            <x-widget.button type="submit" variant="danger" confirm="Turn off two-factor authentication?" confirm-message="Signing in will need only your password.">Turn off</x-widget.button>
        </form>
    @endif
</section>

The QR code is printed with {!! !!} because Fortify draws it as SVG from the user's own secret; nothing in it comes from what someone typed. It sets its colours with attributes, not style="", so a strict Content Security Policy allows it.

Recovery codes

Once it's on, the page lists the recovery codes. Each signs in once, for when the phone is lost. Making new ones replaces the old, so the button asks first with the button's confirm.

resources/views/settings/recovery-codes.blade.php
<div class="grid gap-3">
    <h3 class="font-semibold">Recovery codes</h3>
    <p>If you lose your phone, each of these signs you in once. Keep them somewhere safe, like a password manager.</p>
    <ul class="grid grid-cols-2 gap-1 rounded-lg border p-4 font-mono text-sm select-all">
        @foreach ($recoveryCodes as $code)
            <li>{{ $code }}</li>
        @endforeach
    </ul>
    <form method="POST" action="{{ route('two-factor.regenerate-recovery-codes') }}">
        @csrf
        <x-widget.button type="submit" variant="secondary" confirm="Make new recovery codes?" confirm-message="The codes you have now will stop working.">New codes</x-widget.button>
    </form>
</div>

The sign-in challenge

After a correct password, Fortify sends anyone with two-factor authentication on to its challenge page. Tell it which view to show, along with the password confirmation screen from above:

app/Providers/FortifyServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;

class FortifyServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Shown after the password, to anyone who turned two-factor authentication on.
        Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge'));

        // confirmPassword and the password.confirm middleware ask for the password
        // here first. Without it, the settings page can't be reached.
        Fortify::confirmPasswordView(fn () => view('auth.confirm-password'));
    }
}

The form posts both fields. Fortify checks a filled-in recovery code first, and the code otherwise. A wrong one comes back under the field it was typed in; when it's the recovery code, its section reopens on its own with the focus in it.

resources/views/auth/two-factor-challenge.blade.php
<form method="POST" action="{{ route('two-factor.login.store') }}" class="grid max-w-sm gap-5">
    @csrf
    {{-- Focus starts on whichever field to fill in: the recovery code's after one was wrong, the code boxes otherwise. --}}
    <x-widget.otp name="code" label="Code from your authenticator app" group="3" :autofocus="! $errors->has('recovery_code')" />
    <x-widget.button type="submit">Sign in</x-widget.button>

    {{-- Fortify checks a filled-in recovery code first, and the code only otherwise. --}}
    <details class="grid gap-4" @if ($errors->has('recovery_code')) open @endif>
        <summary class="cursor-pointer">Lost your phone? Use a recovery code</summary>
        <x-widget.text-input name="recovery_code" label="Recovery code" autocomplete="off" class="mt-4" :autofocus="$errors->has('recovery_code')" />
        <x-widget.button type="submit" variant="secondary" class="mt-4">Sign in with a recovery code</x-widget.button>
    </details>
</form>

Keep the secret secret

  • The settings page shows the secret while it's being set up. Never cache it or make it public: behind auth and a session, Laravel already sends it as private.
  • Keep two_factor_secret and two_factor_recovery_codes in the model's $hidden, so they never end up in JSON.
  • Fortify throttles the challenge with its two-factor rate limiter. Keep it.