Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Twofactorauth Laravel Package

robthree/twofactorauth

RobThree/TwoFactorAuth is a dependency-light PHP library for TOTP and HOTP two-factor authentication. It generates secrets, verifies codes, and creates QR codes for Google Authenticator, Authy, and compatible apps, making 2FA easy to add to Laravel or any PHP app.

View on GitHub
Deep Wiki
Context7

Getting Started

robthree/twofactorauth is not a Laravel package — there is no service provider, no config file, no migrations, no Blade components. It is a pure-PHP TOTP/HOTP primitive with zero framework coupling. Everything Laravel-shaped (storage columns, enrollment UI, middleware, recovery codes) is yours to write. Treat it like a cryptography helper, not like laravel/sanctum.

Install and pin. The library ships RobThree\Auth under PSR-4 and requires a modern PHP (8.x for the current major). Because majors have changed namespace placement, constructor signatures, and default QR provider, pin the major and read the changelog on upgrade:

composer require robthree/twofactorauth:^3.0 bacon/bacon-qr-code:^2.0

You almost certainly also need a local QR renderer. The library's older defaults delegate to remote HTTP services (Google Charts / qrserver), which means you would be POSTing your users' TOTP secrets to a third party. Add bacon/bacon-qr-code (SVG, no ext-imagick needed), endroid/qr-code, or chillerlan/php-qrcode and inject that provider explicitly. ext-gd or ext-imagick is only needed for raster output.

Where to look first. The single class that matters is RobThree\Auth\TwoFactorAuth:

Method Use
createSecret() Generate a base32 secret at enrollment
getQRText($label, $secret) otpauth:// URI — the canonical artifact; QR is just a rendering of it
getQRCodeImageAsDataUri($label, $secret, $size) Data URI for the enrollment <img>
getCode($secret, $time = null) Server-side code generation — for tests and support tooling
verifyCode($secret, $code, $discrepancy = null, $time = null) The only verification entry point

Everything is stateless. The secret is the only state, and where you put it is the entire security model.

First use case is enrollment, not login. Build it first: generate a secret, show the otpauth:// URI as both a QR image and a copyable base32 string, require the user to type a code that verifies, and only then persist. A flow that persists the secret before verification produces locked-out users with a broken second factor and no way to debug it.

use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;

$tfa = new TwoFactorAuth(new BaconQrCodeProvider(), digits: 6, period: 30);

$secret = $tfa->createSecret();            // base32, unpadded, uppercase
$label  = config('app.name') . ':' . $user->email;   // "Issuer:account"
$uri    = $tfa->getQRText($label, $secret);
$img    = $tfa->getQRCodeImageAsDataUri($label, $secret, 220);

Put the constructor in the container once so digits/period/provider are configured in one place:

// app/Providers/TwoFactorServiceProvider.php
$this->app->singleton(TwoFactorAuth::class, fn () => new TwoFactorAuth(
    new BaconQrCodeProvider(),
    digits: 6,
    period: 30,
));

Implementation Patterns

Enrollment with a pending state. Never write the secret to the user row until a code has been verified against it. Two workable shapes:

  1. Pending column: two_factor_secret (encrypted cast) plus two_factor_confirmed_at = null. Unconfirmed rows are treated as "not enrolled" everywhere.
  2. Session stash: hold the secret in the session/encrypted cache keyed by user id with a 10-minute TTL. Cleaner data model, worse for multi-tab and for users who abandon mid-flow.

The pending-column approach is easier to reason about and survives a page refresh:

public function store(Request $request, TwoFactorAuth $tfa)
{
    $request->validate(['code' => ['required', 'digits:6']]);

    $secret = $request->session()->get('2fa.pending_secret');

    if (! $secret || ! $tfa->verifyCode($secret, $request->string('code'), 1)) {
        throw ValidationException::withMessages(['code' => 'That code did not match.']);
    }

    $request->user()->forceFill([
        'two_factor_secret' => $secret,            // 'encrypted' cast handles at-rest crypto
        'two_factor_confirmed_at' => now(),
        'two_factor_recovery_codes' => $this->generateRecoveryCodes(),
    ])->save();

    $request->session()->forget('2fa.pending_secret');
}

Login challenge as a two-phase authentication. Do not put TOTP into your LoginRequest — it breaks the "second factor is a separate step" property and makes rate limiting and lockout muddy. Instead: password auth succeeds → regenerate session → set session(['2fa.user_id' => $id, '2fa.deadline' => now()->addMinutes(5)]) → redirect to the challenge route → an EnsureTwoFactorPassed middleware gates everything behind it.

// middleware
if ($user->two_factor_confirmed_at && ! $request->session()->get('2fa.passed')) {
    $request->session()->put('url.intended', $request->fullUrl());
    return redirect()->route('two-factor.challenge');
}

The session flag must be cleared on logout and on password change. Regenerate the session ID after the TOTP step too, not just after the password step — otherwise a pre-auth session fixation survives into a fully authenticated session.

A dedicated validation rule keeps controllers thin and makes the "discrepancy=1" policy explicit and greppable:

final class TotpCode implements ValidationRule
{
    public function __construct(private string $secret) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || ! preg_match('/^\d{6}$/', $value)) {
            $fail('Enter the 6-digit code from your authenticator app.');
            return;
        }
        if (! app(TwoFactorAuth::class)->verifyCode($this->secret, $value, 1)) {
            $fail('That code is not valid. Check your device clock and try again.');
        }
    }
}

Replay defence. verifyCode is pure math: a code stays valid for its whole 30-second window (plus the discrepancy skew), so a captured code can be replayed. Store two_factor_used_at (or a per-user last-accepted time-step) and reject anything at or below it. Compute the time step as intdiv($time, 30) and compare, or simply reject resubmission of the identical code within the window. Recovery codes are the escape hatch when this misfires, so ship them in the same release.

Recovery codes are entirely yours. The library does not generate them. Emit 8–10 random codes, show them exactly once, store Hash::make() of each, and consume atomically:

DB::transaction(function () use ($user, $code) {
    $user->lockForUpdate(); // or optimistic compare-and-swap on the JSON column
    // constant-time compare against each hash, then remove the matched one
});

API / SPA shape. Sessions are awkward for token clients. Issue a short-lived, single-purpose "2FA pending" token (signed, audience-scoped, 5 minutes) instead of the session flag, and exchange it for a full API token only after verifyCode succeeds. Never let the pending token authenticate anything else.

Testing. The library reads wall-clock time internally; Carbon::setTestNow() will not reach it unless it happens to use the same clock. Always pass the timestamp explicitly, which is exactly what the fourth argument is for:

$tfa = app(TwoFactorAuth::class);
$secret = $tfa->createSecret();
$now = 1_700_000_000;

$code = $tfa->getCode($secret, $now);

$this->assertTrue($tfa->verifyCode($secret, $code, 1, $now));
$this->assertTrue($tfa->verifyCode($secret, $code, 1, $now + 30));  // +1 step, inside discrepancy
$this->assertFalse($tfa->verifyCode($secret, $code, 1, $now + 90)); // 3 steps out

Gotchas and Tips

Remote QR providers leak the secret. The otpauth:// URI contains the secret. If you use a chart-style provider, the user's TOTP secret transits a third party and may sit in their access logs. Inject a local provider in the service provider binding and never rely on the constructor default — that default has changed across majors.

Don't render the QR in the login path. getQRCodeImageAsDataUri (and any provider that touches the network or a raster extension) can throw. Enrollment-time only, and always pair it with the plain base32 secret in a <code> block — users on locked-down browsers, password managers, and hardware keys need to type it.

Two-factor exceptions are not validation failures. An invalid base32 secret makes verifyCode throw TwoFactorAuthException rather than return false. If a secret column is ever corrupted, decrypted wrong, or was written before you added the encrypted cast, you get a 500 on the login form instead of a clean error. Wrap verification and treat exceptions as "not enrolled, route to recovery."

Version churn is real. Namespace, constructor arity, and default providers differ between the 2.x and 3.x lines. A ^3.0 constraint is fine; * in a long-lived app will eventually break the QR path silently. Read the upgrade notes before bumping the major.

Discrepancy is a security/lockout dial, not a fix. Default is ±1 period (±30s). Raising it to 2–3 masks unsynchronized client clocks and is a common support-driven hack, but it triples the accepted-code window. Fix NTP on your servers and tell users to enable automatic time on their phones before you widen the window.

Digits and period are not free parameters. Six digits / 30 seconds is the only combination every authenticator honors. Eight digits, 15-second periods, and SHA-256 will silently fail in several popular apps. If you deviate, you must also emit algorithm and digits in the otpauth:// URI and test against the actual apps your users run.

Label formatting carries the issuer. getQRText('MyApp:[email protected]', $secret) produces an issuer, which makes the entry readable in the app instead of a bare domain. No colon means no issuer, and users see a wall of identical "example.com" rows.

Rate limit and lock out the verify endpoint. Six digits is 10^6; with a ±1 window you have three candidate windows. Throttle by user and by IP (Laravel's RateLimiter with a 2fa:{id} key), increment a two_factor_failed_attempts counter, and require a password re-entry after N failures. This is the single most-skipped piece of any 2FA rollout.

Encrypt the secret at rest and keep it out of logs. Laravel's encrypted cast on two_factor_secret gets you AES-GCM for free. Consequently the column is unindexable and unsearchable — that is correct. Make sure $user->toArray() never includes it ($hidden), and that exception handlers, Log::info($request->all()), and Telescope don't capture the enrollment payload. Telescope's request watcher will happily record the base32 secret if it arrives in a form post.

Don't store the secret in the session longer than necessary. A session-stashed secret survives in Redis/DB until expiry and is readable by anything that can read your session store. Prefer the pending-column approach or a very short TTL.

Enable/disable flows need a re-auth gate. Regenerating a secret or burning recovery codes must require the current password (and ideally a fresh TOTP code). Otherwise a hijacked session simply re-enrolls the attacker's own device.

Provide an admin reset path. Users lose phones. php artisan 2fa:reset {user} clearing two_factor_secret, two_factor_confirmed_at, two_factor_recovery_codes, and two_factor_used_at is the difference between a five-minute support ticket and a deleted account.

Where this package earns its keep over Laravel's built-ins: Fortify/Breeze ship a working TOTP implementation. Choose robthree/twofactorauth when you need the provider abstraction (local SVG vs. raster), explicit control over digits/period/algorithm, or HOTP/counter-based flows alongside TOTP. If you just want "2FA on a Laravel login," the first-party path is less code — this package is the low-level primitive you reach for when you are building the whole enrollment and recovery UX yourself anyway.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib