Short Answer

Rate-limit sends and verifies separately. Send limits protect inboxes and infrastructure. Verify limits protect the active code from guessing. Both need tenant, email, IP, and purpose context.

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.

Email

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 / hour

Exact 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)
  reject

Return 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.