delight-im/otp
A PHP library for generating and verifying one-time passwords, with support for time-based and counter-based OTPs, configurable code lengths and HMAC algorithms, and simple APIs for adding two-factor authentication to Laravel or any PHP application.
composer require delight-im/otp
There is no service provider, no config file, no facade, no artisan command. The package is a single class (~150 lines). Before you wire it into anything:
vendor/delight-im/otp/src/Otp.php and read it end to end — it takes five minutes and answers every question below more reliably than any docs.namespace line and the PSR-4 entry in the package's composer.json. The class ships in the Delight\Auth namespace (it's a sibling of delight-im/php-auth), so it's Delight\Auth\Otp, not Delight\Otp.require block for the PHP floor if the host app is old.First use case — TOTP enrollment:
use Delight\Auth\Otp;
$otp = new Otp();
$secret = $otp->createSecret(32); // 32 base32 chars ≈ 160 bits — see Gotchas
$uri = $otp->getProvisioningUri('[email protected]', $secret, 'MyApp');
// Render $uri yourself (bacon/bacon-qr-code, endroid/qr-code) and also
// display $secret for manual entry / "can't scan" fallback.
Verification is one call and returns a plain bool:
$ok = $otp->getInstance()->verify($request->input('code'), $user->otp_secret);
Because it's stateless and dependency-free, the fastest path is to write a thin wrapper (App\Support\Totp) on day one and call only that from controllers, jobs and rules.
Schema. Keep three columns on users (or a dedicated user_otp table if you want history):
$table->text('otp_secret')->nullable(); // encrypted cast
$table->timestamp('otp_confirmed_at')->nullable();
$table->timestamp('otp_last_verified_at')->nullable(); // replay guard
Use Eloquent's encrypted cast for otp_secret, $hidden it, and never log the model. The secret is a bearer credential: anyone with it can mint valid codes forever.
Two-phase enrollment. Generate the secret, store it as pending (otp_confirmed_at = null), show the QR, then require one valid code before flipping otp_confirmed_at. This is the only thing that stops a user with a broken/scanned-wrong secret from locking themselves out. It also catches clock skew at a moment when you can do something about it.
Login flow. After the password check succeeds, branch:
if ($user->otp_confirmed_at) {
// do NOT log them in yet — put a signed, short-lived "2fa pending" token in session
return redirect()->route('2fa.challenge');
}
Keep the half-authenticated state in the session (or a signed URL), not as a boolean on the user object.
Replay prevention. The library has none. TOTP codes stay valid for the whole window, so a code captured from a shoulder-surf or a proxy log can be reused for ~90 s. Store otp_last_verified_at on success and reject any submission within 90 s:
if ($user->otp_last_verified_at?->gt(now()->subSeconds(90))) {
return back()->withErrors(['code' => 'Code already used.']);
}
For HOTP (getHotp($secret, $counter)) the equivalent is a monotonic stored counter — increment inside a DB::transaction with lockForUpdate() and reject counter <= last_used.
Rate limiting. A 6-digit code is 1-in-a-million per attempt; without a limit that's a few hours of scripted guessing. Wrap the challenge route:
RateLimiter::attempt("2fa:{$user->id}", 5, fn () => /* verify */, 300);
Also invalidate the pending-2FA token after N failures so the attacker has to re-enter the password.
Recovery codes. Ship them. Generate 8–10 random strings at enrollment, Hash::make each into a recovery_codes table, show plaintext once, and consume-on-use with the same rate limit. Every production 2FA rollout that skipped this ends up with a support queue.
Testability — the important one. The class calls time() directly, so Carbon::setTestNow() has no effect. Use the explicit time-slice parameters instead:
$slice = intdiv(now()->timestamp, 30);
$code = $otp->getCode($secret, $slice);
$this->assertTrue($otp->verify($code, $secret, 1, $slice));
Everything else (expired code, adjacent window, wrong code) falls out of that pattern without freezing the clock.
Testing seam. Bind App\Support\Totp as a singleton in a service provider and inject it. The package has 5 stars, 0 dependents and one maintainer — assume you may fork it or swap to pragmarx/google2fa one day. One wrapper class makes that a 20-line change instead of a grep across the codebase.
"012345" is valid. Never cast to int, never compare ==. Validate with /^\d{6}$/ after stripping spaces — users paste 123 456 and 123-456 from authenticator apps constantly.verify() returns bool, it does not throw. Check === true. Wrapping it in try/catch does nothing; treating false as an exception path silently passes bad codes if you invert the condition.32 explicitly.time(). Shared hosting and long-lived containers drift. Run NTP/chrony, alert on offset > 5 s, and keep the discrepancy window at 1 (default, ±30 s). Widening it to 3+ buys tolerance you don't need and triples the brute-force surface.getQrCodeUrl() historically points at chart.googleapis.com, which is dead. Use getProvisioningUri() and render the QR locally. Also URL-encode label/issuer yourself if you assemble the otpauth:// URI by hand — plus-addressed emails and app names with spaces break naive concatenation.= padding is emitted. Google Authenticator, 1Password and Authy all accept it; a strict third-party parser might not. Smoke-test enrollment on at least two apps before shipping.app/Support and adjust the HMAC algorithm and digit truncation).session()->put() it, and only persist on confirmation.$otp->getCode($user->otp_secret, intdiv(time(), 30)) and compare to what the app shows. If they differ, the stored secret is wrong (copy/decrypt error) or the server clock is off. If they match, the problem is your form/validation layer, not OTP.Log::info($request->all()) on either is a credential leak; strip them with $request->except([...]) in any middleware that records payloads.$this->mock(Otp::class); mock your own wrapper instead.How can I help you explore Laravel packages today?