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

Otp Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Open 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.
  2. Confirm the 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.
  3. Check the 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.

Implementation Patterns

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.

Gotchas and Tips

  • Codes are strings with leading zeros. "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.
  • Default secret length is 16 base32 chars (~80 bits). RFC 4226 §4.1 recommends 160 bits. Always pass 32 explicitly.
  • Clock skew is the #1 cause of "valid code rejected". Verification depends entirely on server 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.
  • Unpadded base32 secret. No = 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.
  • Everything is hardcoded SHA-1 / 6 digits / 30 s. No setter, no interface, no extension point. If you need SHA-256/512 or 8-digit codes you must fork (MIT — just vendor the file into app/Support and adjust the HMAC algorithm and digit truncation).
  • Enrollment race: if you regenerate the secret after the QR is already on screen (double-submit, back button), the user scans a stale secret and every code fails forever. Generate once, session()->put() it, and only persist on confirmation.
  • Debugging a failing code: server-side, dump $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 hygiene: the enrollment endpoint receives the plaintext secret and the challenge endpoint receives live codes. Log::info($request->all()) on either is a credential leak; strip them with $request->except([...]) in any middleware that records payloads.
  • No interfaces means no mocking. Don't try to $this->mock(Otp::class); mock your own wrapper instead.
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.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console