wnx/laravel-tfa-confirmation
Adds two-factor authentication confirmation to Laravel apps. Require users to re-enter a TFA code before sensitive actions via middleware and routes, with Fortify support and customizable views.
This package adds step-up authentication to session-based Laravel apps: before a user can hit a protected route, they must re-enter their TOTP code from their authenticator app (the same secret used by Fortify/Jetstream two-factor). It is not a login package — it assumes the user is already authenticated and already has 2FA enabled.
Prerequisites (check these before anything else):
users table has two_factor_secret and two_factor_confirmed_at, and your User model uses Fortify's TwoFactorAuthenticatable trait.web middleware group). This package stores the "confirmed" flag in the session, so it does nothing useful for stateless Sanctum/API tokens.Install and publish:
composer require wnx/laravel-tfa-confirmation
php artisan vendor:publish --provider="Wnx\TfaConfirmation\TfaConfirmationServiceProvider"
Publishing gives you config/tfa-confirmation.php (confirmation lifetime, redirect target, view name) and the Blade confirmation screen. Read the config file first — it is the smallest file in the package and tells you the whole flow.
Register the middleware alias (Laravel 11/12 style):
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tfa-confirmation' => \Wnx\TfaConfirmation\Http\Middleware\RequiresTwoFactorConfirmation::class,
]);
})
Or in app/Http/Kernel.php::$middlewareAliases on Laravel 10.
First use case — protect a destructive settings page:
Route::middleware(['auth', 'tfa-confirmation'])->group(function () {
Route::get('/settings/api-tokens', [ApiTokenController::class, 'index']);
Route::delete('/settings/api-tokens/{token}', [ApiTokenController::class, 'destroy']);
Route::delete('/account', [AccountController::class, 'destroy']);
});
The first request without a confirmation redirects to the package's confirmation screen, the user types their 6-digit code, and is bounced back to the originally requested URL. For the next N minutes (config duration, commonly a few hours) the session is considered confirmed.
Verify the wiring in 30 seconds:
php artisan route:list | grep -i tfa # the confirmation route exists
php artisan tinker --execute="dump(config('tfa-confirmation'));"
Then hit a protected URL in the browser with a user that has 2FA enabled and watch the redirect chain — that is your fastest sanity check.
Route group, not per-action. Confirm once, act many times. Wrap the whole sensitive area (billing, API tokens, team member management, account deletion) in one tfa-confirmation group so the user is not re-prompted on every click within the confirmation window:
Route::middleware(['auth', 'tfa-confirmation'])->prefix('settings')->group(function () {
Route::get('/security', ...);
Route::put('/security/password', ...);
});
Two-tier sensitivity. If some actions must always require a fresh code (e.g. "delete workspace"), don't put them in the long-lived group. Instead keep them in a separate group and shorten the lifetime for that route by overriding the config at boot, or check freshness manually in the controller:
// Controller-level escalation for the rare "always confirm" action
abort_unless(TwoFactorConfirmation::isConfirmedWithin(now()->subMinutes(5)), 423);
Render the right UI in Blade. Use the confirmation state to decide whether to show a normal button or an "unlock to continue" prompt, instead of letting every click bounce through a redirect:
@if (tfa_confirmation_confirmed())
<button type="submit">Delete API token</button>
@else
<a href="{{ route('tfa-confirmation.confirm') }}">Confirm your 2FA code to continue</a>
@endif
Inertia / Livewire / SPA frontends. The middleware returns a redirect to the confirmation page for normal requests. In Inertia this surfaces as a 302 that Inertia follows automatically — the confirmation screen must therefore be a full Inertia page (or you publish the view and wrap it). After confirmation the user lands back on intended, which Inertia will render normally. If you prefer to keep the SPA state, have your modal POST the code to the package's confirm route via router.post and then router.reload().
Pair it with Fortify password confirmation deliberately. Fortify's password.confirm is the other step-up flow. Do not stack both by accident: pick 2FA for "prove it's you now", and reserve password.confirm for flows where the authenticator device may be unavailable. If you do use both, order the middleware ['auth', 'password.confirm', 'tfa-confirmation'] so the user is never asked twice on one request.
Testing routes behind the middleware. The cheapest approach is to drop the middleware in feature tests that are not about confirmation:
$this->withoutMiddleware(\Wnx\TfaConfirmation\Http\Middleware\RequiresTwoFactorConfirmation::class);
And write dedicated confirmation tests by forging the session flag the package writes (inspect session()->all() once after a real manual confirmation to get the exact key):
$this->actingAs($user)
->withSession([/* the package's confirmation key */ => now()->timestamp])
->get('/settings/api-tokens')
->assertOk();
Add one test with a user who has no 2FA enabled — that is the case that breaks in production.
Guard against non-2FA users explicitly. Decide the policy and encode it: either deny access to the protected area entirely ($user->two_factor_confirmed_at === null → redirect to the 2FA setup screen) or fall back to password.confirm. Silent allow is the most common production incident.
Combine with audit logging. Wrap the confirmation route's controller or listen for the confirmation event (if published) to log "user X performed step-up auth for /account DELETE" — step-up auth is exactly the interaction auditors ask about.
tfa-confirmation must run after auth/web. If it runs first, $request->user() is null and you get a 500 or a redirect loop. Always list it second.web group only and double-check with route:list.intended redirect only replays GETs. A DELETE or PUT form submission interrupted by the confirmation screen loses its body. Design the UI so confirmation happens before the destructive form is submitted (render the prompt on the GET page), not in the middle of a POST.php artisan session:clear / a session driver change (file → redis) silently invalidates everyone's confirmation. That's fine — just don't treat it as an account-wide flag.array session driver in tests stores nothing. Confirmed-state assertions will fail mysteriously if you switched drivers; use the default test config or set the session flag explicitly.config/tfa-confirmation.php, run php artisan config:clear. The classic "I changed the duration and nothing happened" bug.two_factor_confirmed_at, not two_factor_secret. Users mid-enrolment have a secret but no confirmation; treating them as 2FA-enabled creates a prompt they can't satisfy.->renderable() handler, and return a JSON payload with the confirm URL so the frontend can open a modal instead of a full navigation.URL::temporarySignedRoute, the round trip through the confirmation screen can outlive the signature. Confirm first, then generate the signed URL.duration per environment (short in production, null/long in local so you don't type codes all day); swap the confirmation route's controller by re-declaring the route in your own routes/web.php if you need custom post-confirmation redirect logic.Log::info(session()->all()) inside a protected route after a manual confirmation shows exactly which key/duration the package writes; if it's absent, the middleware never ran (alias typo) or the confirm route posted to a different session (domain/SESSION_DOMAIN mismatch).src/Http/Middleware/RequiresTwoFactorConfirmation.php takes five minutes and answers 90% of the questions above.How can I help you explore Laravel packages today?