kind-work/two-fa
A Laravel package for adding two-factor authentication to your applications. It helps secure user accounts with an additional verification step and integrates into existing auth flows, giving teams a simple way to strengthen login protection.
Install the package
composer require kind-work/two-fa
Note the current line is 2.0.0-beta.2 — pin the version explicitly ("kind-work/two-fa": "2.0.0-beta.2") until v2 is stable.
Publish config and migrations
php artisan vendor:publish --provider="KindWork\TwoFa\TwoFaServiceProvider"
php artisan migrate
The publish step drops a config/two-fa.php (issuer name, digits, time window, recovery-code count) and the columns needed to store the TOTP secret and recovery codes on your users table. Read the config file first — it is the fastest way to understand the package's feature surface.
Opt the User model in
Add the package trait (e.g. HasTwoFactorAuthentication) to App\Models\User. This gives you helpers such as enableTwoFactorAuth(), confirmTwoFactorAuth($code), disableTwoFactorAuth(), and the recovery-code APIs.
First use case: enrolment The canonical first flow is user enables 2FA:
Don't start with the login challenge; start with enrolment. It's the part with the most UX and persistence decisions, and it validates that your config, migration, and trait wiring are correct.
Where to look first
README.md for the exact trait/facade/middleware names (they changed across v1 → v2).config/two-fa.php after publishing.Enrolment as a two-step state machine. Store the secret but keep 2FA "inactive" until the user confirms a generated code. This prevents locking users out with a secret they never actually scanned. Route: POST /two-fa/enable → render QR → POST /two-fa/confirm → flip active flag and emit recovery codes.
Login challenge as middleware or a dedicated route. Two common patterns:
web group that redirects authenticated-but-unverified users to a challenge screen.Auth::attempt(), check $user->hasTwoFactorAuthEnabled() and redirect to a /two-fa/challenge GET/POST pair; store a short-lived session flag on success.Pick one and be consistent — mixing both is a common source of bypass bugs.
Recovery codes. Generate them server-side, hash them at rest if the package supports it (or store encrypted), show them once, and treat each as single-use. Implement a "regenerate recovery codes" endpoint behind password confirmation — users lose these constantly.
Enforcing 2FA by policy. Rather than bolting this onto every controller, wrap it in a middleware group or a RequireTwoFactor middleware applied per-route-group. For admin panels, enforce it globally and allow a grace period via a two_fa_grace_until timestamp.
Localization. As of 2.0.0-beta.2 the package ships a German translation (PR #37). Publish translations with the vendor publish tag and you'll get lang/vendor/two-fa/de/…. To add another locale, copy the en files into lang/vendor/two-fa/{locale}/ and translate the keys — no package fork required. Set config('two-fa.locale') (or rely on app()->getLocale()) and keep your challenge/enrolment views using __('two-fa::…') so the package strings resolve correctly.
Blade/Inertia integration. The QR code is typically rendered as an inline SVG or data URI from the provisioning URI. If you're on Inertia/Livewire, return the provisioning URI + QR SVG from your controller and let the front end render it; don't rely on package views if you have a JS UI.
Testing. Fake time with Carbon::setTestNow() when asserting TOTP codes — codes are time-windowed. Build a small test helper that generates a valid code for a given user so feature tests for the login challenge stay deterministic.
2.0.0-beta.2 is a moving target; the only change in this release is a German translation, but the v2 line as a whole may still shift trait/middleware names. Pin the exact version and read the changelog before bumping.encrypted cast on the secret column yourself. A DB leak with plaintext TOTP secrets is a full account-takeover vector.config/two-fa.php before assuming a bug in your flow.config/two-fa.php in production, run php artisan config:cache. Forgetting this is the #1 reason issuer/name changes "don't take effect."users table can lock it. Add nullable columns in a separate migration and backfill nothing (null = disabled), rather than adding non-null defaults.whereNull('used_at') update) to avoid race conditions.session()->regenerate()) to prevent fixation. The package won't do this for you.lang/vendor/two-fa/en/…, re-publish or diff after upgrading — a new release may add keys your local copies don't have.How can I help you explore Laravel packages today?