Short Answer
OTP abuse is not one behavior. It can be inbox flooding, cost abuse, credential stuffing support, brute-force guessing, or harassment against a target email address. One global limit will miss important cases.
Use Several Small Limits
Tenant
Protect the whole account from runaway integration bugs or compromised API keys.
Prevent repeated sends to the same inbox across a short window.
IP
Slow bulk abuse from the same network source before it reaches email volume.
Challenge
Stop guessing by locking or expiring a challenge after too many failed verifies.
Send Limits Protect Inboxes
A resend button can be safe from a state-machine perspective and still be abusive from a delivery perspective. Idempotent resend keeps the code stable, but it does not make unlimited email volume acceptable.
send limits:
tenant_id: 1000 / hour
tenant_id + purpose: 300 / hour
normalized_email: 3 / 10 minutes
ip_address: 30 / hourExact numbers depend on your product, but the shape matters: protect the account, the target inbox, and the network source.
Verify Limits Protect The Code
Verification attempts should be stored on the active challenge. If a user fails too many times, mark that challenge locked and require a new send after your retry policy allows it.
verifyOtp(challenge, submittedCode):
if challenge.status != "active":
reject
if challenge.attempts_failed >= maxAttempts:
lock(challenge)
reject
if codeMatches(submittedCode):
markVerified(challenge)
accept
incrementFailedAttempts(challenge)
rejectReturn Calm Errors
Rate-limit errors should be clear enough for real users and vague enough to avoid helping attackers. For send, tell the user to wait before requesting another code. For verify, tell them the code is invalid, expired, or locked.
- Use HTTP 429 for send throttles.
- Return a retry-after value when it is safe to show.
- Do not reveal whether a target email has an account.
- Lock the active challenge after too many failed attempts.
- Log limit hits with tenant, purpose, and coarse source signals.
Guardrails
Stable codes still need limits.
sendotp.email uses idempotent send behavior to prevent resend confusion, but send volume and verify attempts still need explicit abuse controls.
FAQ
Where should OTP rate limits apply?
Apply rate limits at several layers: tenant, normalized email, IP address, purpose, and active challenge attempt count.
Should send and verify have different limits?
Yes. Send limits protect inboxes, delivery cost, and abuse. Verify limits protect the code itself from guessing and should be tied to the active challenge.
How many OTP verification attempts should be allowed?
A small budget such as 5 to 10 failed attempts per challenge is a common starting point. Tune it based on code length, risk, and support data.
Does idempotent resend remove the need for rate limits?
No. Reusing the same active code avoids state confusion, but every resend can still create email volume and abuse signals that need limits.