Rate-Limit OTP Requests for SMS-Based 2FA in Game Marketplace Sign-In
Short answer: rate-limit SMS OTP issuance at several independent boundaries, store only a digest of the code, and make the consume operation atomic. For a game marketplace seller signing in, the useful unit is not just a
Short answer: rate-limit SMS OTP issuance at several independent boundaries, store only a digest of the code, and make the consume operation atomic. For a game marketplace seller signing in, the useful unit is not just an IP address: one actor can rotate addresses, while a shared office or carrier network can put many legitimate sellers behind one address. A practical starting policy is one request per 30 seconds per account, five requests per hour per account, and a separate, tighter ceiling for an IP plus a broader network signal. Tune those values against your login SLO and abuse data, rather than treating them as security constants.
The integration decision is the hard part. In Node.js, the route handler is easy; preserving counters across replicas, making retries idempotent, and explaining a blocked request to a seller are where the operational work lives.
Small limits are not enough.
How should a Node.js service rate-limit OTP requests for SMS-based 2FA?
Protect the scarce action: creating and sending a new challenge. Do not rate-limit only the verification endpoint, and do not let a resend button create a new code every time it is clicked. A request should first pass an abuse check, then obtain a short-lived issuance record, then enqueue one delivery job. The delivery provider can fail after the record exists, so the response must not imply that a new code was generated twice.
For a seller account, use at least three keys with different purposes:
| Key | What it catches | Typical response |
|---|---|---|
| Account identifier | One account being harassed or brute-forced | Slow issuance and verification |
| Normalized phone number | Rotating accounts targeting one destination | Stop sending temporarily |
| IP or network signal | Broad automated abuse | Challenge, throttle, or deny |
The account and phone-number keys need a privacy decision. Hashing a normalized value with a server-held pepper limits accidental exposure in logs and cache inspection, but it does not remove the need for retention and access controls. Keep the raw phone number out of rate-limit keys and event payloads whenever the workflow does not require it.
Why do counters fail under load?
A local in-memory map looks attractive in a single Node.js process. It becomes the wrong boundary as soon as the service has two replicas, a restart, or a worker that handles delivery separately. A seller can send two requests to different replicas and receive two texts inside the intended 30-second window. That is a correctness failure, not a minor observability wrinkle.
Use a shared store that supports an atomic increment with an expiration, or a small service that exposes the same atomic decision. The operation should return the count and the remaining delay in one decision. Do not implement βread, compare, writeβ as three independent network calls; concurrent requests can both observe an acceptable count.
A fixed window is simple and often good enough for the first version, but its boundary burst is real: five requests at 12:59:59 and five more at 13:00:00 are ten sends in two seconds. A token bucket gives smoother behavior, while a sliding window gives a more literal recent-history limit at higher storage cost. Pick the model your team can inspect during an incident, because the operational trade-off is more important than a mathematically elegant limiter that nobody can explain at 03:00.
Here is a language-neutral issuance decision represented in Go. The store interface is deliberately generic; the important contract is atomicity and a server-controlled expiration, not a particular database.
package otp
import "time"
type Decision struct {
\tAllowed bool
\tRetryAfter time.Duration
}
type CounterStore interface {
\tIncrementWithExpiry(key string, window time.Duration) (count int, err error)
}
func AllowIssue(store CounterStore, accountKey, phoneKey, ipKey string, now time.Time) (Decision, error) {
\tkeys := []string{accountKey, phoneKey, ipKey}
\tlimits := []int{5, 5, 20}
\twindow := time.Hour
\tfor i, key := range keys {
\t\tcount, err := store.IncrementWithExpiry(key, window)
\t\tif err != nil {
\t\t\treturn Decision{}, err
\t\t}
\t\tif count > limits[i] {
\t\t\treturn Decision{Allowed: false, RetryAfter: time.Until(now.Add(window))}, nil
\t\t}
\t}
\treturn Decision{Allowed: true}, nil
}
Production code should avoid consuming all three quotas permanently when the first request is rejected by a later quota. That can be handled with a reservation transaction, compensating decrements where the store guarantees them, or a deliberately conservative policy documented as part of the abuse budget. This is a trade-off worth making explicit before an incident, because a βfairβ rollback can itself become a race.
The counter is not the security boundary. The atomic decision is.
How do retries and verification stay safe?
Give an issuance request an idempotency key derived from the authenticated session or a client-generated request identifier, with a short retention window. The same key should return the same issuance result without sending another message. Never use the OTP itself as that key.
Generate the OTP with a cryptographically secure random source, store a keyed digest plus an expiry, and compare the submitted value in constant time where the platform provides that primitive. Mark the record consumed in the same atomic operation that checks the digest and expiry. A valid code should be single-use; an invalid attempt should increment a separate verification counter, preferably on both the account and challenge.
Keep issuance and verification limits distinct. A seller who mistypes once should not lose the ability to request a code, while an attacker who guesses repeatedly should not get unlimited attempts just because sends are under quota. Return a generic user-facing message for unknown accounts and avoid putting whether a phone number exists into timing or status differences.
Node.js callers also need a clear failure contract around the queue boundary. If the rate-limit store is unavailable, fail closed for anonymous issuance when the risk budget requires it; for an already authenticated recovery flow, a tightly bounded fallback may be acceptable. Record the decision reason internally, but do not expose store names, account existence, or raw delivery errors to the browser.
What should be measured before changing the limits?
Set an SLO for the complete path, not just the HTTP handler: accepted request to delivery-provider acceptance, and accepted request to successful verification. Track p50 and p95 latency, rejection rates by key type, duplicate idempotency hits, queue age, provider errors, and the percentage of challenges that expire unused. A low send-error rate can still hide a bad user journey if the queue is late and codes expire.
Capacity planning belongs here. Estimate peak legitimate seller sign-ins, multiply by the number of challenges a seller may create during a busy interval, and leave headroom for retries and a regional failover. The shared counter store must handle that write rate plus verification reads without turning the login path into a dependency with no budget. Put its latency and error rate on the same dashboard as the login SLO.
Test the policy with a deterministic clock and a fake atomic store, then exercise the real store with concurrent requests. Include these cases: two replicas receiving the same account at once, a resend at 29 and 31 seconds, a queue retry after a provider timeout, a consumed code submitted again, and a failover that changes the client IP. The expected result should be a decision and a reason, not merely a 429.
Rollback without reopening the abuse path
Ship limit values behind configuration that can be changed without redeploying the Node.js application, but validate bounds and audit every change. A rollback should revert an overly strict threshold to the last known-good policy; it should not disable all checks. Keep an emergency deny rule for a confirmed abusive destination or account, and preserve a kill switch for the delivery queue separately from the verification path.
Before launch, run a short canary against a small seller cohort and compare successful verification, delivery latency, duplicate sends, and support contacts with the baseline. If the canary shows extra sends, fix idempotency or atomicity before loosening limits. If it shows more legitimate failures, adjust the relevant key or window rather than raising every ceiling.
The durable design is a small state machine: request, reserve, send, verify, consume, expire. The exact store or SMS transport matters less than making each transition observable, bounded, and safe under concurrency.
References
- OWASP, Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP, Multifactor Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
- RFC 4086, Randomness Requirements for Security: https://www.rfc-editor.org/rfc/rfc4086
- RFC 6238, TOTP: Time-Based One-Time Password Algorithm: https://www.rfc-editor.org/rfc/rfc6238
- Google, Email sender guidelines: https://support.google.com/a/answer/81126
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.