pragmarx/google2fa
Google2FA is a PHP implementation of Google Two-Factor Authentication, supporting HOTP (RFC 4226) and TOTP (RFC 6238). It lets you generate secret keys, QR codes, and verify one-time passwords from Google Authenticator and similar apps, with an optional Laravel bridge.
pragmarx/google2fa is the algorithm, not the feature. It gives you generateSecretKey(), getQRCodeUrl(), verifyKey(), and verifyKeyNewer() — everything about enrollment UX, persistence, throttling, and rendering is yours to build. Plan for that up front.
Minimal install:
composer require pragmarx/google2fa
composer require bacon/bacon-qr-code # only if you render QR codes yourself
If you want the Facade, config file, and 2fa middleware, install the Laravel bridge instead (pragmarx/google2fa-laravel), which pulls this package in. For most apps the bridge saves a day of plumbing; for a single login flow the base package plus ~60 lines of your own code is leaner.
The base package ships no service provider, so app(Google2FA::class) works only via Laravel's auto-resolution and gives you a fresh instance each time. Bind it as a singleton in AppServiceProvider if you ever call setAlgorithm()/setWindow(), otherwise those settings silently vanish between resolutions:
$this->app->singleton(\PragmaRX\Google2FA\Google2FA::class, fn () => new \PragmaRX\Google2FA\Google2FA());
First use case to implement — enrollment, not login:
$secret = $google2fa->generateSecretKey(); // 32 chars in v9+
$user->forceFill(['google2fa_secret' => $secret])->save();
$url = $google2fa->getQRCodeUrl(config('app.name'), $user->email, $secret);
// render $url as a QR, then verify the first code the user types
$ok = $google2fa->verifyKey($secret, $request->string('code'));
Where to look first in the docs: Validation Window, verifyKeyNewer, and the 9.0.0 breaking change section. Those three determine whether your implementation is merely functional or actually secure.
Two-phase enrollment. Never let a user flip to "2FA enabled" until they've proven the secret works. Store the secret with google2fa_confirmed_at = null, show the QR, verify one code, then set the timestamp and issue recovery codes. Users who scan and lose the page shouldn't be locked out of an account that was never really protected.
Two-step login. Authenticate the password, then hold the user in the session under a 2fa.pending_id key rather than calling Auth::login():
if ($user->hasTwoFactorEnabled()) {
$request->session()->put('2fa.pending_id', $user->id);
return redirect()->route('2fa.challenge');
}
Auth::login($user);
$request->session()->regenerate();
On the challenge route, resolve the pending user, verify, then Auth::login() + session()->regenerate() + forget('2fa.pending_id'). This keeps 2FA out of your LoginController and makes it impossible to skip by hitting /dashboard directly.
Use verifyKeyNewer for anything user-facing. Plain verifyKey() accepts a code for the whole window, so a shoulder-surfed or intercepted code stays reusable for 60+ seconds. Persist the returned timestamp:
$ts = $google2fa->verifyKeyNewer(
$user->google2fa_secret,
$request->string('code'),
$user->google2fa_last_ts,
config('google2fa.window', 1)
);
if ($ts !== false) {
$user->forceFill(['google2fa_last_ts' => $ts])->save();
}
Note the !== false — the return is int|false, and treating it as a boolean is a bug waiting to happen.
Centralize the config. Wrap the instance in a small TwoFactorService that reads config('google2fa.window'), config('google2fa.algorithm'), and config('google2fa.key_regeneration'). Then one env change tunes every call site, and your setAlgorithm() state can't drift.
Enforce it with middleware. A RequireTwoFactor middleware on privileged route groups (/admin, /billing, /settings/security) that redirects unenrolled users to enrollment is the highest-leverage integration point. Pair it with a configurable grace period so existing users aren't bricked on deploy.
Render QR without Imagick. BaconQrCode's ImagickImageBackEnd requires ext-imagick, which many CI images and small containers lack. SvgImageBackEnd needs nothing and scales cleanly:
$writer = new Writer(new ImageRenderer(new RendererStyle(300), new SvgImageBackEnd()));
$svg = $writer->writeString($google2fa->getQRCodeUrl($issuer, $user->email, $secret));
Testing. There's no Carbon hook — the package reads wall-clock time directly. Don't hardcode 123456; generate the expected code from the same secret inside the test ($google2fa->getCurrentOtp($secret)) and assert the flow. For the "wrong code" case, mutate a digit of that generated value rather than picking a random 6-digit string, which can collide with the valid window.
The v9 secret length change is the upgrade trap. generateSecretKey() now returns 32 characters. Check your migration column (the README's own warning), any size:16 / max:16 validation rule, and test assertions. Use string('google2fa_secret', 64) or text so the next bump doesn't force another migration. Existing 16-char secrets keep working — no data migration needed.
Instance state is silently destructive. setAlgorithm() and setWindow() are per-instance. Resolve Google2FA twice (once to enroll, once to verify) and change the algorithm on one, and every code fails with no error message. Singleton binding or a wrapper service eliminates the class of bug.
Time drift is the #1 support ticket. The default $window = 1 tolerates ±30s. Before you widen it, make sure NTP is running on the app servers — a wide window is a security cost, not a fix. $window = 0 is worse: it rejects codes the user saw a few seconds ago, since TOTP advances every 30s. $window = 4 is a reasonable ceiling for consumer apps; anything larger and an attacker gets real runway.
Rate-limit the challenge route, not just login. A 6-digit code with a window of 1 means ~3 valid codes out of 1,000,000 — but unlimited attempts defeats that. Use RateLimiter::hit() keyed on user ID and IP, with progressive lockout after ~5 failures, and log the attempts. Return an identical error for "invalid," "expired," and "already used."
Encrypt the secret at rest — and understand the cost. The TOTP secret is equivalent to a password: read access to that column means permanent code generation. Use an encrypted cast and add the attribute to $hidden and exclude it from $fillable. The trade-off: losing APP_KEY invalidates every user's 2FA. If that's unacceptable, use a dedicated encryption key with a documented rotation runbook.
Codes are strings. Leading zeros are significant. $request->input('code') cast to int will fail roughly 10% of the time, and users paste "123 456" and "123-456". Normalize with preg_replace('/\D/', '', $code) and validate digits:6 before hitting the package.
getQRCodeUrl() is just an otpauth:// URI — treat it as a credential. Don't log it, don't cache the response, don't render it on a page reachable after the browser is closed. Serve enrollment over HTTPS only, and require a recent password confirmation before regenerating a secret (regeneration locks out the old device instantly, which is also your recovery path).
No recovery codes, no backup, no revocation. The package has zero opinion here. Ship hashed single-use recovery codes at enrollment, plus an admin/support path to reset 2FA with identity verification. Without it, a phone replacement becomes a support escalation every single time.
Don't put 2FA in front of API tokens. TOTP protects interactive session auth. If you're issuing Sanctum tokens, the second factor belongs at the token-issuance endpoint, not on every API request — and never on VerifyCsrfToken-exempt routes.
Pin 9.x and read the changelog on bumps. 8.x is maintenance-only now; 7.x is unsupported. The package is mature and widely deployed, but the 9.0 secret-length change shows it will make breaking security improvements in majors — a composer update in CI without a version constraint is how you find out at runtime.
How can I help you explore Laravel packages today?