spomky-labs/otphp
A PHP library for generating one-time passwords using the HOTP (RFC 4226) and TOTP (RFC 6238) algorithms, compatible with Google Authenticator and other apps. Generate, verify, and manage OTP secrets easily in your PHP projects.
Install and pin a safe version. The 11.4.3 release fixed two medium-severity advisories in the provisioning-URI parser (digits overflow → uncatchable DivisionByZeroError, and property mass-assignment from a hostile otpauth:// key). Require ^11.4.3 at minimum, never <11.4.3.
composer require spomky-labs/otphp:^11.4.3
Look here first, in this order:
doc/index.md in the repo — it is short and complete; the README itself is only a signpost.OTPHP\OTP base class — it holds getSecret(), getDigits(), getDigest(), getProvisioningUri() (older 10.x: getUri()), verify(), and the Factory.OTPHP\TOTP and OTPHP\HOTP — thin subclasses adding getPeriod()/getCounter().UPGRADE* notes before any major bump; the create → createFromSecret / generate rename landed in 11.1 and still catches people out.First real use case — enrol a user and get a QR on screen. The whole enrollment loop is three lines of library code wrapped in your own persistence:
use OTPHP\TOTP;
$totp = TOTP::generate(); // 160-bit Base32 secret
$totp->setLabel($user->email); // required for a valid URI
$totp->setIssuer(config('app.name')); // shown in the authenticator app
return view('auth.two-factor-setup', [
'secret' => $totp->getSecret(), // persist encrypted, do NOT show twice
'uri' => $totp->getProvisioningUri(),
]);
Render $uri as a QR code server-side (BaconQrCode or simplesoftwareio/simple-qrcode) or hand the URI to a JS renderer. The docs' old Google Charts example is dead — 11.5.0 replaced it with a local BaconQrCode example.
Minimum schema for a Laravel app:
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->nullable(); // encrypted cast
$table->text('two_factor_recovery_codes')->nullable(); // encrypted cast
$table->timestamp('two_factor_confirmed_at')->nullable();
});
protected function casts(): array
{
return [
'two_factor_secret' => 'encrypted',
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
];
}
The encrypted cast is non-negotiable: a Base32 TOTP secret is a permanent password. Note the consequence — rotating APP_KEY silently invalidates every user's 2FA, so treat key rotation as a 2FA migration event.
Wrap it in one injectable service. Nothing in this library is Laravel-aware, so isolate construction logic once instead of calling TOTP::createFromSecret() from six controllers.
namespace App\Support;
use OTPHP\TOTP;
class TwoFactorService
{
public function make(?string $secret = null): TOTP
{
$totp = $secret
? TOTP::createFromSecret($secret)
: TOTP::generate();
return $totp
->setLabel(config('app.name') . ':' . auth()->user()->email)
->setIssuer(config('app.name'))
->setDigits(config('two-factor.digits', 6))
->setPeriod(config('two-factor.period', 30));
}
public function verify(string $secret, string $code): bool
{
return $this->make($secret)->verify($code);
}
}
Bind it in a service provider if you want to swap implementations later (or keep the Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider contract in mind — if the app already uses Fortify, implement that contract on top of OTPHP rather than bolting on a second 2FA stack).
Enrollment must be two-phase. Generate the secret into the session (or a pending_two_factor_secret column), show the QR, and only promote it to two_factor_secret + set two_factor_confirmed_at after the user proves possession with a valid code. Persisting on page render is the single most common bug: the user refreshes, gets a new secret, and the code they just typed never works.
public function confirm(Request $request, TwoFactorService $otp)
{
$request->validate(['code' => ['required', 'digits:6']]);
$secret = $request->session()->get('pending_2fa_secret');
abort_unless($secret && $otp->verify($secret, $request->code), 422,
'That code did not match.');
$request->user()->forceFill([
'two_factor_secret' => $secret,
'two_factor_recovery_codes' => $this->generateRecoveryCodes(),
'two_factor_confirmed_at' => now(),
])->save();
$request->session()->forget('pending_2fa_secret');
return redirect()->intended('/dashboard');
}
Challenge on login with a middleware, not a redirect maze. Mark the session as 2fa.passed right after credential validation, then let an EnsureTwoFactorIsPassed middleware bounce every authenticated route to /two-factor/challenge. Keep the challenge route itself outside the middleware group and rate-limit it (RateLimiter::for('2fa', fn ($r) => Limit::perMinute(5)->by($r->user()?->id ?: $r->ip()))) — six digits is brute-forceable in minutes without it.
Add replay protection yourself. verify() returns true for the whole tolerance window, so the same code can be submitted repeatedly for ~90 seconds. Store the last accepted code (hashed or the window timestamp) and reject duplicates:
if (Cache::add("2fa:used:{$user->id}:{$code}", true, now()->addMinutes(5)) === false) {
abort(422, 'Code already used.');
}
Recovery codes are your job, not the library's. Generate 8–10 random strings, store them hashed (Hash::make), compare with Hash::check, delete on use, and let the user regenerate the whole set with a current TOTP code. Show them exactly once.
Support the awkward real-world cases:
two_factor_secret for locked-out users, guarded behind an --force confirmation and an audit log.user:two-factor middleware that only enforces when two_factor_confirmed_at is set, plus an "enforce for admins" listener on Login.$hotp->getCounter() must be incremented and persisted transactionally after each successful verification, or codes desync.The third argument to verify() changed meaning. In 10.x the window was a count of time steps; in 11.x drift tolerance is expressed as seconds (leeway, and a setLeeway()/getLeeway() pair on TOTP). Copy-pasting 10.x tutorials gives you a 1-second tolerance and mystifying "wrong code" reports. If you only ever read one migration note, make it this one (11.0.3 shipped a doc PR specifically because it confused people).
Never pass a formatted date string as the timestamp. verify($code, $timestamp) wants a UNIX integer in UTC. strtotime($local) or Carbon::parse() on a user-supplied value silently shifts the window. Use now()->timestamp — Laravel's now() is timezone-agnostic for timestamps, so this is safe even with APP_TIMEZONE set.
Secrets are Base32, not arbitrary bytes. createFromSecret() expects Base32 without worrying about padding, but don't hand it a random binary string from Str::random() and expect valid codes. Use TOTP::generate() and optionally a custom secret size in 11.4+.
Label and issuer are effectively required. getProvisioningUri() throws if they're missing (11.4.0 relaxed some cases), and a colon in the label breaks Google Authenticator's spec parsing. Use AppName:[email protected] — the library now splits issuer from label, and 11.4.0 explicitly tolerates a mismatch between the label prefix and the issuer parameter.
Treat Factory::loadFromProvisioningUri() as untrusted input. It's the documented entry point for importing otpauth:// URIs (migration from another app, a QR scanner), and it's exactly what the 11.4.3 advisories targeted. Wrap it:
try {
$otp = \OTPHP\Factory::loadFromProvisioningUri($uri);
abort_unless($otp instanceof \OTPHP\TOTP, 422);
} catch (\OTPHP\Exception\InvalidProvisioningUriException $e) {
abort(422, 'Unsupported authenticator link.');
}
11.4.0 added a full custom exception hierarchy — catch the specific type, not \Throwable.
Stay on SHA-1 / 6 digits / 30 s for compatibility. setDigest('sha256') and 8 digits work with Google Authenticator and FreeOTP, but old Microsoft/enterprise authenticators and some hardware tokens don't advertise it in the URI. The algorithm and digits parameters in the provisioning URI only help apps that read them.
Don't log the secret or the code. Laravel's $request->all() in an exception handler, Telescope's request payload, and Sentry breadcrumbs all happily record a one-time password. Add code and two_factor_secret to app/Exceptions/Handler's dontFlash and scrub them in Telescope's RequestWatcher hidden attributes.
Test deterministically. verify() takes an explicit timestamp and now()-based generation honors Carbon::setTestNow(). For fixture-driven tests, RFC 6238 test vectors work because the library exposes at($timestamp) / now() on TOTP — assert against $totp->at(59) rather than sleeping.
Debugging flow, in order: (1) decode the stored secret and confirm it is the Base32 string the user scanned; (2) print $totp->getProvisioningUri() and check the label/issuer/period; (3) check the server clock (date -u) — a container 2 minutes off produces 100% failure; (4) confirm you're not double-verifying and consuming the code in two different code paths.
Extension points to know: OTPHP\OTP is final and its constructor is private — customisation happens through setters (setDigits, setDigest, setIssuer, setLabel, setParameter) and the Factory, not subclassing. setSecret() is public since 10.0.3, which is the supported way to reuse an existing instance after an import.
How can I help you explore Laravel packages today?