michaeldzjap/twofactor-auth
Lightweight Laravel package that adds two-factor authentication using TOTP codes compatible with Google Authenticator. Includes QR code provisioning, recovery codes, route middleware and events, making it straightforward to secure your application's login flow.
Install via Composer and publish the config. Pin a floor of 3.4.1 — earlier 3.x releases can treat the OTP as a float and mangle codes (see Gotchas):
composer require michaeldzjap/twofactor-auth:^3.4.1
php artisan vendor:publish --provider="MichaelDzjap\TwoFactorAuth\TwoFactorAuthServiceProvider"
Add your MessageBird credentials to .env (the config keys mirror the provider's expectations):
MESSAGEBIRD_API_KEY=your_live_or_test_key
MESSAGEBIRD_ORIGINATOR=MyApp
The first place to look is the published config/twofactor.php — it defines the MessageBird key/originator, the verification template, and the phone-number attribute the package reads from. Read it before writing any code; almost every behavioral quirk traces back to a value here.
Next, wire the package into your User model:
use MichaelDzjap\TwoFactorAuth\TwoFactorAuthenticatable;
class User extends Authenticatable
{
use TwoFactorAuthenticatable;
}
You need to add the columns the trait expects — typically two_factor_phone_number (E.164 format) and two_factor_enabled (boolean), plus optionally a place to stash the MessageBird verify_id between send and check. A migration:
$table->string('two_factor_phone_number')->nullable();
$table->boolean('two_factor_enabled')->default(false);
$table->string('two_factor_verify_id')->nullable();
First use case: a settings page where a logged-in user enrolls a phone. Call $user->sendTwoFactorCode() (or invoke the package's verifier directly), which triggers MessageBird Verify to send an SMS. Then call $user->verifyTwoFactorCode($request->code) and, on success, set two_factor_enabled = true and persist. That enable-then-verify flow is the minimum viable integration and the one to get working before touching the login path.
Treat $request->code as an opaque string end-to-end — never (int), never (float). Validate it as ['required', 'string', 'digits:6'] and pass it straight through.
Login-flow enforcement. The canonical pattern is: after Auth::attempt() succeeds, check $user->hasTwoFactorAuthEnabled(). If false, complete the login. If true, hold the user in a pending state (session key with the user ID, Auth::loginUsingId() deferred), redirect to a challenge screen, and only call Auth::login() once verifyTwoFactorCode() returns true.
if ($user && $user->hasTwoFactorAuthEnabled()) {
session(['2fa.pending_user_id' => $user->id]);
$user->sendTwoFactorCode();
return redirect()->route('twofactor.challenge');
}
Middleware guard. Combine the trait with a EnsureTwoFactorVerified middleware on your authenticated route group. The middleware checks a session flag (2fa.verified) rather than re-hitting MessageBird on each request — verification happens once per session, not per request.
Reusable verification service. Wrap the trait calls in a small TwoFactorService so your controllers stay thin and you have one place to mock in tests:
class TwoFactorService
{
public function challenge(User $user): bool
{
return $user->sendTwoFactorCode();
}
public function verify(User $user, string $code): bool
{
return $user->verifyTwoFactorCode($code);
}
}
Note the string $code signature — keep the type honest so a future refactor can't quietly reintroduce numeric coercion (the bug fixed in 3.4.1).
Challenge request validation. A single FormRequest for both enrollment and challenge:
public function rules(): array
{
return [
// `digits:6` rejects decimals, signs, and whitespace; `numeric` does not.
'code' => ['required', 'string', 'digits:6'],
];
}
protected function prepareForValidation(): void
{
$this->merge(['code' => trim((string) $this->input('code'))]);
}
Avoid numeric, integer, or any (int)/(float) cast on the token — that's exactly the class of input that produced the 3.4.1 fix, and MessageBird tokens are strings, not numbers.
Enrollment vs. challenge. Treat these as two distinct flows even though the underlying API call is the same. Enrollment is "prove you control this number before we enable 2FA"; challenge is "prove you are who the password says." Keep the routes, controllers, and success handlers separate — mixing them is the most common source of half-enabled users stuck without a way in.
Admin recovery path. Because this package has no built-in recovery codes, plan a "disable 2FA" action gated by password re-entry, and a support-scripted way for admins to null out two_factor_phone_number / reset two_factor_enabled for locked-out users.
Testing. MessageBird is called over HTTP, not behind a Laravel-style fake, so in tests bind a stubbed service into the container or use Http::fake() if the underlying client uses Laravel's HTTP client. Assert only on the trait's boolean contract, not the network call. Add a regression test that a leading-zero code ("012345") verifies successfully — that's the shape the float-token bug broke.
app/ namespace or wrapping the calls behind your own interface from day one.12.5. If you're on <3.4.1, upgrade — and regardless, validate with digits:N and never cast the code. If a user reports "the right code doesn't work," check the code path for an integer/float cast before blaming MessageBird.two_factor_verify_id on the user (or in session), verification will silently fail because you're starting a new verify session each time.+14155552671). Normalize before saving — accept the user's local formatting in the UI but store the canonical form. If codes never arrive, this is the first thing to check.config/twofactor.php, run php artisan config:clear (or config:cache again in production). A stale cached originator is a classic "works locally, fails in prod."sendTwoFactorCode() is a billable Verify request. Add per-user throttling at the controller level (e.g., RateLimiter::for('2fa', fn($r) => Limit::perMinute(3)->by($r->user()->id))); MessageBird will start rejecting once you hit its Verify rate caps anyway, and a user can burn credits by mashing "resend."errors[]) in your wrapper. Distinguish between "code wrong" and "verify session expired / not found" — they look identical to the trait but need different UX (retry vs. resend).AppServiceProvider::register() if you want to swap MessageBird for Twilio or an email OTP without touching call sites. The trait methods delegate to whatever is bound.Auth::login() before verification. It's tempting to log the user in after the password check and "just gate with middleware." Don't — session fixation and route leaks are easy, and the package assumes the pending state is unauthenticated. Keep the pending ID in session, not in an authenticated guard.2fa.verified, 2fa.pending_user_id), clear them in your Logout listener, otherwise the next login can inherit a verified session on a shared machine.How can I help you explore Laravel packages today?