Security 12 min read Markdown

Adding Two-Factor Authentication to Laravel with Fortify

A working password is no longer enough. Here is how to wire up TOTP-based two-factor authentication in Laravel using Fortify, from the migration columns through the QR challenge, and how to force every admin account to enrol.

Matt King
Matt King
June 23, 2026
Last updated: June 23, 2026
Adding Two-Factor Authentication to Laravel with Fortify

Your Laravel app has a login form. Passwords are hashed. HTTPS is enforced. You might think the front door is locked, but a stolen password walks straight through it, and people reuse passwords everywhere. Two-factor authentication is the cheapest, highest-leverage control you can add to close that gap.

Laravel Fortify gives you the entire backend for it: TOTP enrolment, the QR code payload, the challenge at login, recovery codes and an encrypted secret at rest. What it does not give you is a UI, by design. This guide walks through wiring all of it up on Laravel 11 or 12, then forcing every admin account to enrol whether they like it or not.


Why 2FA is worth the effort

Credential stuffing is the dull, profitable attack that fills breach reports. An attacker takes a list of email and password pairs leaked from some unrelated site, then sprays them at your login endpoint. Because people reuse passwords, a percentage will hit. Rate limiting slows this down (and you should have it, see our notes on API rate limiting with Sanctum), but it does not stop a single attacker who already has the right password for one account.

Two-factor authentication breaks that economic model. Knowing the password is no longer sufficient. The attacker also needs a code that changes every thirty seconds and lives on a device they do not control. For the cost of one migration and a few views, you neutralise the most common way accounts get taken over.

The mechanism most apps use is TOTP, the time-based one-time password defined in RFC 6238. The server and the authenticator app share a secret once, at enrolment. From then on, both sides hash that secret together with the current Unix time divided into thirty-second windows, and truncate the result to a six-digit number. No network call happens at code-generation time, which is why your authenticator app keeps working on a plane. The app and the server simply agree on the maths.


Installing and configuring Fortify

If Fortify is not already in the project, pull it in and publish its resources.

composer require laravel/fortify
php artisan fortify:install

The install command publishes config/fortify.php, a FortifyServiceProvider, and the migration that backs two-factor authentication. Run the migration once you have looked at it.

php artisan migrate

Open config/fortify.php and find the features array. This is where you switch 2FA on. The two options inside it matter more than they look.

// config/fortify.php

use Laravel\Fortify\Features;

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::twoFactorAuthentication([
        'confirm' => true,
        'confirmPassword' => true,
    ]),
],

'confirm' => true means a user is not considered protected the moment they hit enable. They have to prove they can produce a valid code from their authenticator before 2FA is switched on for real. Without this flag, a user could enable 2FA, fat-finger the setup, and then lock themselves out at the next login because their app never actually held the right secret. Always set it to true.

'confirmPassword' => true forces the user to re-enter their password before they can change 2FA settings. That stops someone with a borrowed, already-logged-in session from quietly disabling protection or harvesting fresh recovery codes.


What the migration adds, and why the secret is safe

The published migration adds three columns to the users table. They carry the entire state of a user's 2FA setup.

// database/migrations/xxxx_add_two_factor_columns_to_users_table.php

Schema::table('users', function (Blueprint $table) {
    $table->text('two_factor_secret')
        ->after('password')
        ->nullable();

    $table->text('two_factor_recovery_codes')
        ->after('two_factor_secret')
        ->nullable();

    $table->timestamp('two_factor_confirmed_at')
        ->after('two_factor_recovery_codes')
        ->nullable();
});

The two_factor_secret holds the shared TOTP secret. This is the single most sensitive value in the whole flow: anyone who reads it can generate valid codes forever. Fortify stores it encrypted using your application's APP_KEY, so a leaked database dump does not hand over working secrets on its own. The same encryption covers two_factor_recovery_codes. If you have ever wondered what that key actually protects, encrypted 2FA secrets are a textbook example, and it is one more reason to treat APP_KEY rotation as a real procedure rather than an afterthought, which we cover alongside Laravel password and hashing security.

two_factor_confirmed_at is the column that the 'confirm' => true setting populates. A user with a secret but a null two_factor_confirmed_at has started enrolling and never finished. That distinction becomes load-bearing when you write enforcement middleware later, so keep it in mind.

The TwoFactorAuthenticatable trait on your User model exposes all of this. The standard Laravel skeleton already applies it; if yours does not, add it.

// app/Models/User.php

use Laravel\Fortify\TwoFactorAuthenticatable;

class User extends Authenticatable
{
    use TwoFactorAuthenticatable;
}

The enrolment flow end to end

Fortify is headless, so it answers HTTP requests and returns JSON or redirects, and you build the screens. Here is the sequence a user moves through, with the route each step hits.

Method & route Purpose
POST /user/two-factor-authentication Generates the secret and recovery codes, begins enrolment. With confirm on, the user is not protected yet.
GET /user/two-factor-qr-code Returns the SVG QR code payload encoding the otpauth URI.
GET /user/two-factor-secret-key Returns the raw secret as text, for manual entry when a camera is not available.
GET /user/two-factor-recovery-codes Returns the current set of single-use recovery codes.
POST /user/confirmed-two-factor-authentication Confirms enrolment with a valid code; sets two_factor_confirmed_at.
POST /user/two-factor-recovery-codes Regenerates the recovery codes, invalidating the old set.
DELETE /user/two-factor-authentication Disables 2FA and clears all three columns.
POST /two-factor-challenge The challenge at login: accepts either a TOTP code or a recovery_code.

The order at enrolment is: POST to start, show the QR code so the user can scan it, then POST to confirmed-two-factor-authentication with the first code their app produces. Once that confirmation succeeds, show the recovery codes and tell them, plainly, to save them somewhere that is not their phone.

At the next login, because the account now has a confirmed setup, Fortify intercepts the login and redirects to the two-factor challenge instead of completing the session. The user submits a code from their app, or a recovery_code if the phone is gone.


Wiring the QR and recovery codes UI

Below is a Blade page for the account settings screen. It assumes a logged-in user and CSRF protection, and it reflects the headless contract: you fetch the QR and codes from Fortify's endpoints and render them yourself.

{{-- resources/views/profile/two-factor.blade.php --}}

@if (! auth()->user()->two_factor_confirmed_at)
    <form method="POST" action="{{ url('/user/two-factor-authentication') }}">
        @csrf
        <button type="submit">Enable two-factor authentication</button>
    </form>
@endif

@if (auth()->user()->two_factor_secret)
    {{-- The QR endpoint returns ready-to-print SVG markup --}}
    <div class="qr">
        {!! auth()->user()->twoFactorQrCodeSvg() !!}
    </div>

    @if (! auth()->user()->two_factor_confirmed_at)
        <form method="POST" action="{{ url('/user/confirmed-two-factor-authentication') }}">
            @csrf
            <label for="code">Enter the code from your authenticator app</label>
            <input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" required>
            <button type="submit">Confirm</button>
        </form>
    @else
        <h3>Recovery codes</h3>
        <p>Store these somewhere safe. Each one works once.</p>
        <ul>
            @foreach (json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true) as $code)
                <li><code>{{ $code }}</code></li>
            @endforeach
        </ul>

        <form method="POST" action="{{ url('/user/two-factor-recovery-codes') }}">
            @csrf
            <button type="submit">Regenerate recovery codes</button>
        </form>
    @endif
@endif

The twoFactorQrCodeSvg() helper on the model is the tidiest way to render the code; it wraps the same payload the /user/two-factor-qr-code endpoint returns. If you prefer to keep the page reactive, a Livewire component fits the same shape: hold the confirmation code in a wire model, call the enable and confirm endpoints from action methods, and re-render the recovery list once two_factor_confirmed_at is set. The flow does not change; only the plumbing does.

One detail worth getting right: only show recovery codes immediately after confirmation, and after an explicit regenerate. Do not leave them rendered on a page the user might walk away from on a shared machine.


Enforcing 2FA for admins

For ordinary users, opt-in 2FA is a reasonable default. For administrators, finance roles, or anyone who can change other people's data, optional protection is a liability. The way to make it mandatory is custom middleware that pushes un-enrolled privileged users to the enrolment page before they reach anything sensitive.

// app/Http/Middleware/RequireTwoFactor.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RequireTwoFactor
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user && $user->is_admin && is_null($user->two_factor_confirmed_at)) {
            // Let them reach the enrolment screen and Fortify's own endpoints,
            // otherwise they would be redirected in a loop.
            if (! $request->routeIs('two-factor.setup') &&
                ! $request->is('user/two-factor-*', 'user/confirmed-two-factor-authentication')) {
                return redirect()->route('two-factor.setup')
                    ->with('status', 'Two-factor authentication is required for admin accounts.');
            }
        }

        return $next($request);
    }
}

Note the check is on two_factor_confirmed_at, not two_factor_secret. An admin who started enrolling and bailed out still has a secret on their record, but they are not protected, so the middleware must keep redirecting until confirmation lands. Register it against the admin route group.

// routes/web.php

Route::middleware(['auth', 'admin', RequireTwoFactor::class])
    ->prefix('admin')
    ->group(function () {
        Route::get('/dashboard', DashboardController::class)->name('admin.dashboard');
        // ...
    });

The carve-out for Fortify's own routes is the part people forget. If you redirect every admin request without confirmed 2FA, you also block the enrolment POST and the confirmation POST, and the admin can never finish. Let those endpoints and the setup page through, redirect everything else. This pattern slots neatly into broader access-control thinking, which the OWASP Top 10 for Laravel walks through under broken access control.


Recovery codes and the lost-phone problem

The single most common 2FA support ticket is "I got a new phone and now I cannot log in." Recovery codes are the answer, and they only help if the user kept them. At the login challenge, a user submits a recovery_code instead of a TOTP code; Fortify validates it against the encrypted set, logs them in, and burns that one code so it cannot be used again.

// The challenge accepts either field; the user picks one.
// POST /two-factor-challenge
// { "code": "123456" }            <- normal TOTP login
// { "recovery_code": "ABCD-1234" } <- when the device is gone

Once a user is back in via a recovery code, two things should happen. First, surface a prompt to regenerate codes, because their printed set is now one short and they may not remember which. Second, let them re-scan a fresh QR code onto the new device. Disabling and re-enabling 2FA rotates the secret cleanly, which is the correct move when the old phone is genuinely lost rather than merely upgraded.

If a user has neither their device nor any recovery codes, no self-service path can save them without a hole an attacker could drive through. That case has to fall back to a verified human process: confirm identity out of band, then have an admin disable 2FA on the account so the user can re-enrol. Treat that procedure as a security-sensitive action and log it.


Where passkeys fit

TOTP stops password reuse and credential stuffing dead, but it has one real weakness. A convincing phishing page can ask for the password and the current six-digit code, then replay both against the real site within the thirty-second window. The code is a shared secret with no awareness of which site it is being typed into.

WebAuthn and passkeys close that gap. The credential is cryptographically bound to your site's origin, so a code entered on a lookalike domain is worthless to the attacker; the browser simply will not produce a valid assertion for the wrong origin. Passkeys also drop the manual code entry entirely, which users tend to prefer once they have tried it.

You do not have to choose. Ship Fortify's TOTP today, because it is ready now and removes the bulk of your account-takeover risk, then add passkeys as a stronger second factor or a passwordless option when you have the room. Offering both lets users pick, and lets you steer privileged accounts towards the phishing-resistant route.


Do this today

Pick the one action that matters most: turn on Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true]), run the migration, and put the RequireTwoFactor middleware on your admin route group so no privileged account can operate without it. Everything else, including the user-facing UI and passkeys, can follow once admins are covered.

Want to know whether your current auth setup leaves obvious gaps before you ship the change? Run a free StackShield scan and we will flag the weak spots in your Laravel configuration.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

Does Laravel Fortify support two-factor authentication out of the box?

Yes. Fortify ships with TOTP-based two-factor authentication as a first-class feature. You enable it in the features array inside config/fortify.php, run the migration that adds the relevant columns, and Fortify registers all the routes for enrolling, confirming, challenging and disabling 2FA. You still need to build the views, because Fortify is a headless backend and deliberately ships no UI of its own. That separation is what lets you use it with Blade, Livewire, Inertia or a separate front end.

What authenticator apps work with Fortify two-factor authentication?

Any app that implements the standard TOTP algorithm described in RFC 6238 will work. That covers Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden and most password managers with a built-in code generator. Fortify generates a standard otpauth provisioning URI and renders it as a QR code, so the user simply scans it. There is no vendor lock-in because the protocol is open.

How do recovery codes work in Fortify?

When a user confirms two-factor authentication, Fortify generates a set of single-use recovery codes and stores them encrypted on the user record. Each code can be redeemed once at the two-factor challenge in place of a TOTP code, which is what lets someone back in after losing their phone. Once a code is used it is removed from the set. Users can regenerate the whole batch at any time, which invalidates the previous codes, so prompt them to do that if they suspect the codes leaked.

Can I force certain users to enable two-factor authentication?

Fortify itself treats 2FA as opt-in per user, but you can enforce it with your own middleware. The pattern is to check whether the authenticated user has a confirmed two-factor setup, and if not, redirect them to an enrolment page before they can reach protected routes. This is the right approach for admins and other privileged accounts, where leaving 2FA optional defeats the point. You scope the middleware to the routes or role that require it.

Is TOTP two-factor authentication as secure as passkeys?

TOTP is a large step up from passwords alone and stops the bulk of credential-stuffing and password-reuse attacks. It is not phishing-proof, though, because a convincing fake login page can capture both the password and the current code and replay them in real time. WebAuthn and passkeys are bound to the site origin, which makes them resistant to that kind of relay attack. Many teams ship TOTP now and add passkeys as a stronger option later, rather than waiting.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.