liberusoftware/two-factor-authentication
A Laravel/PHP package from Liberu Software for adding two-factor authentication to your applications. It supports TOTP-based 2FA, QR code enrollment, recovery codes, and straightforward integration with existing user models and login flows.
This is a small, opinionated module: it gives you the policy and primitives for TOTP (RFC 6238) plus single-use recovery codes, not a full UI. Expect to wire the views yourself. Treat the low star count / zero dependents as a signal to read the source before trusting it in production — it is roughly 10–20 files, so budgeting an hour to skim src/ is realistic.
composer require liberusoftware/two-factor-authentication
php artisan vendor:publish --provider="LiberuSoftware\TwoFactorAuthentication\TwoFactorServiceProvider"
php artisan migrate
Then:
config/two-factor.php is where the enforcement policy lives (likely: which guards/roles are required to have 2FA, grace period before lockout, issuer name for the otpauth URI, number of recovery codes, and the code window/drift tolerance). Everything else in the package keys off this file.two_factor_secret (encrypted cast), two_factor_confirmed_at, and a two_factor_recovery_codes table or JSON column. If you already have Fortify/Jetstream two-factor columns on users, reconcile these before running migrate or you will get duplicate-column errors.User model. The package almost certainly ships a HasTwoFactorAuthentication trait or TwoFactorAuthenticatable interface; implements + use is the intended entry point.bootstrap/app.php (Laravel 11+) or Http/Kernel.php under an alias, e.g. 2fa, and attach it to your authenticated route group.First use case to get working end-to-end: enroll → confirm → enforce → recover.
otpauth:// URI as a QR code (bacon/bacon-qr-code or a JS lib), store the secret as pending.two_factor_confirmed_at. This is the step that prevents half-enrolled users getting locked out.Get that loop green in a test before touching any UI polish.
Two-phase enrollment (pending → confirmed). Never flip a user to "2FA required" on secret generation alone. The natural shape:
$secret = $user->createTwoFactorSecret(); // stored, not yet active
return view('2fa.setup', ['qr' => $user->twoFactorQrCodeUri($secret)]);
// ...
$user->confirmTwoFactor($request->input('code')); // sets confirmed_at, returns codes
The confirm call is where you hand back the recovery codes once — that response is the only time they should ever be readable in plaintext.
Enforcement as middleware, not as a login listener. Attach 2fa after auth and before anything that mutates state, so an unverified session cannot POST to business endpoints. A middleware placement like:
Route::middleware(['auth', '2fa'])->group(function () { ... });
// challenge + logout routes outside the group
keeps the redirect logic in one place. If the package exposes an "enforcement policy" object, drive it from config (e.g. enforce_for_roles: ['admin']) rather than hardcoding role checks in the middleware — that's the seam the package is actually selling.
Atomic recovery-code consumption. The one-time property is worthless if implemented as read-then-write. Wrap it:
DB::transaction(function () use ($user, $code) {
return $user->recoveryCodes()
->where('used_at', null)
->lockForUpdate()
->get()
->first(fn ($row) => Hash::check($code, $row->code));
});
Look for whether the package's consumeRecoveryCode() does this internally. If it does a plain ->delete() on a matched hash without a lock, you have a replay window under concurrent requests — patch it, or add a unique index plus a conditional UPDATE ... WHERE used_at IS NULL and check affected rows.
Compose with Fortify/Breeze rather than fighting it. If you already use Fortify's two-factor feature, disable its columns and challenge view and let this package own the secret storage; otherwise you get two competing two_factor_secret sources and the middleware will check the wrong one. Whichever you keep, delete or rename the other set of migrations.
Event-driven auditing. Enable-2FA, disable-2FA, failed challenge, and recovery-code-use should each fire an event (or at minimum a Log::info with user id and IP, never the secret). Listeners here are the cheapest way to satisfy an audit requirement and to alert on repeated recovery-code use.
Testing. Freeze time; TOTP is time-dependent and flaky tests are the #1 complaint with 2FA packages:
$this->travelTo(now()->startOfMinute());
$code = $user->currentTwoFactorCode(); // or generate via the package's TOTP helper
$this->post('/2fa/verify', ['code' => $code])->assertRedirect('/dashboard');
Test the negative cases explicitly: expired window, reused recovery code, code reuse within the same window, and middleware redirect when confirmed_at is set but the session flag is absent.
Config-driven behaviour over subclassing. Tune window/digits/period, recovery-code count, and issuer via config. Extension beyond that (custom code generator, custom notifier) should go through container bindings — check TwoFactorServiceProvider::register() for singleton/bind calls you can override in AppServiceProvider.
window, don't raise it to paper over server clock skew — fix NTP. Authenticator apps on phones with manual time are the usual culprit.Hash::check an encrypted value the same way, and a DB leak hands out second factors. Verify before shipping.2fa before verified/verified before 2fa both "work" until a user is unverified and un-enrolled — decide which redirect wins and test it. Also exclude the challenge, logout, and recovery routes from the group or you build an infinite redirect.two_factor_secret / two_factor_recovery_codes columns against Fortify or a hand-rolled implementation is the most common install failure. Read the published migration before migrate, especially in an existing app.config:cache in deploy. Publishing config then forgetting php artisan config:cache in the deploy script means your issuer name and enforcement flags silently revert. Add it to the pipeline and clear on rollback.currentTwoFactorCode() or the verify action on a user with a null secret should return a clean 4xx/redirect, not a DecryptException 500. Wrap the challenge controller entry point.Log::debug calls in the package source before enabling debug logging in staging."liberusoftware/two-factor-authentication": "1.0.*" or a commit hash), commit composer.lock, and keep a fork path in mind. If the TOTP primitive is thin, consider keeping the policy/config layer but swapping the code-generation core for pragmarx/google2fa (well-maintained, widely audited) behind the package's interface.AppServiceProvider::register() — check the provider for unqualified bind() calls first so you know which keys are safe to replace.How can I help you explore Laravel packages today?