Security Tip: OTPs Need Rate Limiting Too!
[Tip #110] This is your periodic reminder that Rate Limiting is essential, and for more than just your user/password form! Make sure you've got it on your OTP, or someone will come along and brute-force that 6-digit code.

There is a rather exciting PR happening over on the Livewire Starter Kit: Adding Two Factor Auth Feature. This PR adds (you guessed it!) 2FA to the Livewire Starter Kit - with matching PRs for React and Vue. As you can imagine, this made me very excited (and made me realise my delayed review of the starter kits is already out of date)! Yes, security people get excited by the weirdest things...
As you would expect I jumped on the PR, and took a good look. One rather significant thing jumped out at me though... there was no rate limiting on the 2FA prompt! 😱
It's an easy thing to overlook, and Tony Lea has been incredibly responsive about fixing it, but now feels like the perfect time to remind all of you why Rate Limiting One-Time-Passcode (OTP) inputs is so important!
Rate limiting is essential for login forms, and it's unusual to find a login form without some form of rate limiting implemented. This is it's obvious use case and everyone thinks about it. However, in my experience, it's not unusual to find an OTP input without any rate limiting. It's often overlooked and forgotten when folks are adding OTPs.
Why is rate limiting needed on OTPs?
Consider that OTPs are usually numeric codes 4-8 digits long. Which means you're probably only dealing with between 1,000
and 100,000,000
possible combinations.
For a typical Time-based One-Time Passcode (TOTP), which is what Tony was adding to the Starter Kit, you've usually got the range 000,000 - 999,999
to work within, and 30 seconds to submit a correct code.
If your application can handle thousands of requests per second, that a significant number of guesses you can attempt over a 30 second period.
An attacker probably only needs less than an hour of brute-force guessing to find a valid code and get in, bypassing the OTP entirely.
However, if you have rate limiting - maybe only allowing 5 attempts per minute - the chance of a correct code being guessed is basically zero.
Here's a simplified version of the rate limiting that was added to the PR:
public function submitCode($code)
{
// Validate inputs
$this->validate();
// Check if rate limit hit, and reject request
if (RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
throw ValidationException(...);
}
// Verify the OTP
$valid = app(VerifyTwoFactorCode::class)(...);
if ($valid) {
// Finish the login process
app(CompleteTwoFactorAuthentication::class)($user);
// Clear rate limiter (for subsequent requests)
RateLimiter::clear($this->throttleKey($user));
// Redirect to the intended page
return $this->redirectIntended(...);
}
// Increment rate limit counter on failed attempt
RateLimiter::hit($this->throttleKey($user));
$this->addError('auth_code', 'Invalid authentication code. Please try again.');
}
My homework to you is to check if you've correctly configured rate limiting on your OTP inputs. If you haven't, then do so today!
If you found this security tip useful, subscribe to get weekly Security Tips straight to your inbox. Upgrade to a premium subscription for exclusive monthly In Depth articles, or drop a coin in the tip jar to show your support.
When was the last time you had a penetration test? Book a Laravel Security Audit and Penetration Test, or a budget-friendly Security Review!
You can also connect with me on Bluesky, or other socials, and check out Practical Laravel Security, my interactive course designed to boost your Laravel security skills.