Twilio Verify: Rate Limiting OTP Without Locking Out Real Users
Twilio Verify's built-in rate limiting sounds great until your client's customers start getting locked out. Here's how I actually handle it.
Twilio Verify's default rate limiting will stop a naive enumeration attack. It will also lock out a real user who fat-fingered their phone number twice, and then you're getting a support ticket at 11pm. Getting the balance right requires understanding what Twilio actually does under the hood — and layering your own logic on top of it.
What Problem Twilio Verify Is Actually Solving
Phone-based OTP has one ugly vulnerability: enumeration. An attacker can fire requests at your /send-otp endpoint with sequential or harvested phone numbers, figure out which ones have accounts, and build a list. Or they brute-force the six-digit code itself — 1,000,000 possibilities isn't that many if you can hammer the verification endpoint fast enough.
Twilio Verify addresses both sides. On the send side, it throttles how many verification codes you can fire to a single number in a time window. On the check side, it locks a verification after a configurable number of wrong attempts. That's genuinely useful. The problem is the defaults aren't tuned for your app, and the error responses don't give you enough to differentiate "attacker" from "confused user" without doing some work yourself.
I integrated Verify for a healthcare client a couple years back — patient portal login, SMS second factor. The first week in production we had three support calls from patients who legitimately couldn't get in because they'd mistyped their number during registration and burned through retries trying to figure out why the code wasn't arriving. That's when I started actually thinking about this instead of just trusting the defaults.
How Twilio Verify Rate Limits Work
Twilio enforces limits at a few layers:
- Per number, per service: By default you can send roughly 5 verifications to the same number within a 10-minute window before Verify starts returning
429/ error code60203. - Check attempt limits: A single verification SID gets locked after a configurable number of failed check attempts (default is 5). After that, even the right code returns
60202(max check attempts reached). - Geographic and carrier limits: Some carriers throttle SMS. This isn't Twilio's fault but it looks the same to your users.
Twilio documents these at a high level but the exact thresholds can shift and some are configurable per-service in the console. Don't hardcode assumptions about the numbers — handle the error codes, not the counts.
A Working Implementation
Here's the Laravel service class I actually use. I wrap the Twilio SDK calls and layer Redis-backed rate limiting on top so I control the user-facing experience before Twilio's limits even enter the picture.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Twilio\Rest\Client;
use Twilio\Exceptions\RestException;
class VerifyService
{
private Client $twilio;
private string $serviceId;
// How many send attempts we allow before we stop hitting Twilio at all
private const MAX_SENDS_PER_WINDOW = 3;
private const SEND_WINDOW_SECONDS = 600; // 10 minutes
// How long we suppress sends after our own limit is hit
private const LOCKOUT_SECONDS = 1800; // 30 minutes
public function __construct()
{
$this->twilio = new Client(
config('services.twilio.sid'),
config('services.twilio.token')
);
$this->serviceId = config('services.twilio.verify_service_id');
}
public function sendCode(string $e164Phone): array
{
$lockKey = 'verify:locked:' . $e164Phone;
$countKey = 'verify:sends:' . $e164Phone;
// Check our own lockout first — don't hit Twilio if we've already decided no
if (Cache::has($lockKey)) {
$ttl = Cache::get($lockKey . ':ttl', self::LOCKOUT_SECONDS);
return [
'success' => false,
'reason' => 'rate_limited',
'retry_after' => $ttl,
];
}
$sends = (int) Cache::get($countKey, 0);
if ($sends >= self::MAX_SENDS_PER_WINDOW) {
// Set our lockout and bail before Twilio sees the request
Cache::put($lockKey, true, self::LOCKOUT_SECONDS);
Cache::put($lockKey . ':ttl', self::LOCKOUT_SECONDS, self::LOCKOUT_SECONDS);
Log::warning('Verify: local rate limit hit', ['phone_suffix' => substr($e164Phone, -4)]);
return [
'success' => false,
'reason' => 'rate_limited',
'retry_after' => self::LOCKOUT_SECONDS,
];
}
try {
$verification = $this->twilio
->verify
->v2
->services($this->serviceId)
->verifications
->create($e164Phone, 'sms');
// Increment our counter — use atomic increment with expiry on first write
if ($sends === 0) {
Cache::put($countKey, 1, self::SEND_WINDOW_SECONDS);
} else {
Cache::increment($countKey);
}
return [
'success' => true,
'status' => $verification->status, // 'pending'
];
} catch (RestException $e) {
return $this->handleSendException($e, $e164Phone);
}
}
public function checkCode(string $e164Phone, string $code): array
{
try {
$result = $this->twilio
->verify
->v2
->services($this->serviceId)
->verificationChecks
->create([
'to' => $e164Phone,
'code' => $code,
]);
if ($result->status === 'approved') {
// Clear our send counter — fresh start after successful auth
Cache::forget('verify:sends:' . $e164Phone);
Cache::forget('verify:locked:' . $e164Phone);
return ['success' => true];
}
return ['success' => false, 'reason' => 'invalid_code'];
} catch (RestException $e) {
return $this->handleCheckException($e, $e164Phone);
}
}
private function handleSendException(RestException $e, string $phone): array
{
// 60203 = max send attempts reached (Twilio side)
// 60200 = invalid parameter (usually bad phone number format)
// 21211 = invalid 'To' phone number
$code = $e->getCode();
Log::error('Verify: send failed', [
'twilio_code' => $code,
'phone_suffix' => substr($phone, -4),
]);
return match ((int) $code) {
60203 => ['success' => false, 'reason' => 'rate_limited', 'retry_after' => 600],
60200, 21211 => ['success' => false, 'reason' => 'invalid_number'],
default => ['success' => false, 'reason' => 'provider_error'],
};
}
private function handleCheckException(RestException $e, string $phone): array
{
$code = (int) $e->getCode();
Log::error('Verify: check failed', [
'twilio_code' => $code,
'phone_suffix' => substr($phone, -4),
]);
return match ($code) {
// 60202 = max check attempts reached — this verification SID is dead
60202 => ['success' => false, 'reason' => 'max_attempts'],
// 20404 = verification not found (expired, already used, or wrong number)
20404 => ['success' => false, 'reason' => 'not_found'],
default => ['success' => false, 'reason' => 'provider_error'],
};
}
}
The key move here is running my own Redis-backed counter in front of Twilio. I hit my limit at 3 sends per 10 minutes; Twilio's limit is 5. That means I never let a legitimate user exhaust Twilio's quota and hit a less recoverable state. My lockout message can say "try again in 30 minutes" because I know exactly when it expires. Twilio's 429 just says you're rate limited — figuring out when to retry requires reading headers or guessing.
The Gotchas That Will Bite You
The 20404 on check is a mess. When a verification expires (10 minutes by default), checking it returns 20404 — the same code you get if the number doesn't match any pending verification. There's no way to tell the user "your code expired" vs. "that number has no pending verification" from the error alone. I track verification timestamps in my own table so I can give a useful message.
Phone number format matters more than you'd think. Twilio Verify requires E.164 format (+12065551234). If your front end collects a bare 10-digit number and you forget to normalize it, you'll get 21211 and spend 20 minutes confused. I run everything through libphonenumber before it touches the service layer:
use libphonenumber\PhoneNumberUtil;
use libphonenumber\PhoneNumberFormat;
function toE164(string $raw, string $defaultRegion = 'US'): ?string
{
$util = PhoneNumberUtil::getInstance();
try {
$parsed = $util->parse($raw, $defaultRegion);
if (!$util->isValidNumber($parsed)) {
return null;
}
return $util->format($parsed, PhoneNumberFormat::E164);
} catch (\Exception) {
return null;
}
}
Don't expose which numbers have accounts. Your /send-otp endpoint should return the same response whether the phone number exists in your system or not. If you return a fast "that number isn't registered" you've built your own enumeration oracle. Always call Verify (or pretend to), then handle the result internally.
The approved status doesn't guarantee anything downstream. Verify tells you the code matched. It doesn't tell you the user actually owns that phone — SIM swapping is real. For high-stakes auth (financial transactions, account recovery), you probably want additional signals. For a standard second factor on login, it's fine.
Watch your Verify service settings in the console. The code expiry (default 10 minutes), max check attempts, and some rate limit thresholds are configurable per-service. I've seen developers spend days debugging behavior that could've been changed with two clicks. Know your settings.
When I'd Reach for Twilio Verify
Verify is the right tool when you need phone-based OTP and you don't want to build the delivery, retry, and attempt-tracking infrastructure yourself. The abstraction is genuinely good — you're paying for a managed state machine around the verify/check lifecycle, not just SMS delivery.
I use it for:
- SMS second factor on login
- Phone number verification during registration
- Step-up auth before sensitive actions (changing a payment method, exporting PHI)
I'd skip it or supplement it when:
- You need TOTP (authenticator apps) — Verify supports it but you might want
pragmarx/google2fafor tighter local control - You're doing very high volume and the per-verification cost adds up — rolling your own delivery on top of Twilio Programmable SMS with your own code storage can be cheaper at scale
- You need richer audit trails than Verify exposes — I've had compliance requirements that needed more granular logs than Twilio's console provides
Closing
Twilio Verify is a solid API that saves real implementation time, but "batteries included" doesn't mean "configure and forget." The rate limiting protects you from the worst attacks but it'll hurt your legitimate users if you don't put your own layer in front of it. Spend an afternoon getting that right — it's cheaper than the support calls.
Need help shipping something like this? Get in touch.