OTP
Security: OTP
Delivered one-time passcodes (Cbox\Id\Otp\) are an auth factor, so their
controls are load-bearing. This page states the threat model, the crypto choices,
and the honest limits.
What makes a short code safe
A 6-digit numeric code lives in a ~10^6 (≈ 2^20) space. It is not safe because it is hard to guess — it is safe because of the caps around it. Get the caps right and the code length is almost incidental:
| Control | Mechanism | Where |
|---|---|---|
| Hashed at rest | keyed HMAC of the code; raw code never stored | KeyedOtpHasher, Models\OtpChallenge |
| Single-use | consumed_at set under a row lock in the verify transaction |
DatabaseOtpService::settle() |
| TTL | expires_at (default 5 min); expired codes fail |
DatabaseOtpService, config otp.ttl_seconds |
| Attempt cap | attempts vs max_attempts (default 5); then the challenge locks |
DatabaseOtpService::settle() |
| Issue throttle | per recipient+purpose+IP and per recipient (all purposes/IPs) — the latter bounds bombing when the attacker rotates purpose/IP | DatabaseOtpService::issue(), config otp.issue.max_per_window, otp.issue.per_recipient_max |
| Verify throttle | global per-IP and (recipient path) per recipient+purpose across IPs | DatabaseOtpService::verifyThrottled() / verifyRecipientThrottled(), config otp.verify.max_per_window, otp.verify.per_recipient_max |
| Live-only recipient finder | verifyLatest() targets only the newest unconsumed, unexpired, under-cap challenge — a locked/expired one is skipped, not returned |
DatabaseOtpService::verifyLatest() |
| Minimum code length | config below 6 digits is floored to 6 (a 10^4 space is refused) | OtpServiceProvider::clampedLength(), config otp.code_length |
| No enumeration | wrong code and unknown recipient give the same uniform result | OtpResult, OtpFailureReason |
| No timing oracle | constant-time compare runs on every path, incl. the miss (decoy) | DatabaseOtpService::settle(), OtpHasher::decoy() |
| Environment scope | BelongsToEnvironment — cross-env verify is impossible |
Models\OtpChallenge |
Why a keyed hash, not bcrypt or a plain hash
The at-rest value must survive a database dump. Two properties are in tension:
- A plain fast hash (SHA-256) of a 6-digit code is useless at rest: an attacker who dumps the table brute-forces the ~10^6 space in well under a second.
- A slow password hash (bcrypt/argon2) would fix the at-rest problem, but it is hit on every verify attempt — turning the attempt cap and rate limiter into a CPU-amplification lever (each guess costs a full bcrypt) and adding a DoS surface on the verify path.
So the module uses a keyed HMAC (hash_hmac('sha256', code, subkey)), where the
subkey is derived via HKDF from the crypto master key — the same key class
that seals MFA secrets, held in config / a secret store, not in the database. A
dump therefore does not reveal codes: an attacker needs the master key too, and
even with both the online caps and the 5-minute TTL still bound the attack. The
HMAC compare is cheap and constant-time, so the caps — not hash slowness — do
the security work, which is exactly where the guarantee should rest.
All primitives are vetted PHP core (random_int, hash_hkdf, hash_hmac,
hash_equals); nothing is hand-rolled.
No enumeration, no oracle
verify() returns a uniform OtpResult. A wrong code and an unknown /
consumed challenge both return Invalid — the reason never lets a caller probe
which recipients or challenges exist. Expired and Locked are returned only to a
caller that already presented a valid challenge id (not an enumeration signal), so a
host can still prompt "request a new code". Crucially, the constant-time hash
compare runs on every path, including when no challenge is found (against a
decoy hash), so timing does not distinguish a miss from a wrong guess.
The plaintext code appears in exactly one place — the OtpDelivery handed to the
channel. It is never returned to the caller, never written to an audit row, and
never placed in an exception message.
Honest limits
- The code's entropy is not the control. Do not rely on length; rely on the TTL
- attempt cap + verify throttle. Those are the invariants to protect in review.
- SMS is only as secure as SIM-swap resistance. SMS OTPs can be intercepted via SIM-swap, SS7, or device malware. Prefer a phishing-resistant primary factor (passkey / authenticator TOTP) and treat SMS as step-up or recovery. This package delivers the code; it cannot make the SMS bearer channel trustworthy.
- Email OTP inherits email's trust. A code emailed to a compromised mailbox is compromised. Email OTP is a possession-of-inbox check, not a strong factor.
- This is a primitive. Whether an OTP satisfies a given step-up policy is the host's decision; the module enforces the mechanics, not the policy.
Operational caveats
Two guarantees depend on how the host deploys the module:
- The single-use / attempt-cap serialization needs a locking database.
verify()reads the challenge withSELECT … FOR UPDATEinside a transaction, so two concurrent verifies of the same code cannot both succeed. This holds on MySQL/MariaDB (InnoDB) and PostgreSQL. On engines that ignore row locks — SQLite, orMyISAM— the lock is a no-op and, under genuine concurrency, the single-use guarantee degrades to best-effort. Run OTP against a locking engine in production; SQLite is fine for tests but not for a live verify path. - The rate limiter needs a shared, persistent cache store. The issue/verify caps
live in Laravel's
RateLimiter(the cache). With thearraydriver they reset every request and enforce nothing; with a per-node store they are not shared across app servers, so the effective limit multiplies by the node count. Pointcache.defaultat a shared store (Redis/Memcached/database) so the caps are global. The at-rest per-challenge attempt cap is the one bound that survives a missing/again-reset limiter — it is enforced in the row, not the cache. - A queued mailer can still leak the code channel-side. The plaintext code is
never persisted by this module, but
EmailOtpChannelhands it to the framework mailer; if that mailer is queued, the code is serialized into the job payload on your queue backend until the job runs. Keep the queue store as trusted as the mail transport, or deliver OTP mail synchronously.
Auditing
Issuance (otp.issued), failed verifies (otp.verify_failed), lockouts
(otp.locked) and successful verifies (otp.verified) are recorded on the
hash-chained audit trail — with the challenge id, purpose, channel and recipient,
and never the code. See core-concepts/audit-streaming.md.