Marketplace Report Access: Rate-Limit SMS-Based OTP Requests Before Attachment Delivery
Short answer: in Node.js, rate-limit SMS-based 2FA OTP requests by account, normalized phone number, and source network, reserve those quotas atomically before dispatch, and make each challenge single-use and idempotent.
Short answer: in Node.js, rate-limit SMS-based 2FA OTP requests by account, normalized phone number, and source network, reserve those quotas atomically before dispatch, and make each challenge single-use and idempotent.
A useful decision rule is this: persist the decision before sending anything, and make report-download authorization expire separately from the code. For a marketplace operator requesting a generated payout report by email attachment, the code is a step-up credential, not a delivery guarantee. A retrying browser, a shared office NAT, and a delayed carrier message must not turn one authentication action into an unbounded send loop.
The trade-off is deliberate: stricter limits reduce abuse but can strand a legitimate operator when a carrier is slow. I would rather expose a controlled retry path with an auditable state than pretend that a second send is harmless.
What should one OTP request be allowed to do?
Start with invariants rather than a single number. One valid code may be accepted once, an OTP request must have a server-generated challenge identifier, and every send attempt needs an audit record with the actor, destination hash, request reason, and outcome. The rate limit applies before the SMS adapter is called.
For this marketplace workflow, bind the challenge to the authenticated session, account, and normalized phone number. A request to export a 30-day payout report can create a challenge; it cannot reuse one created for a password reset. The report job may finish while the user waits, but its attachment URL should require a fresh authorized session or a short-lived capability.
The limits are policy, not a magic security constant. A practical starting policy might permit 3 sends per account in 10 minutes, 5 per phone in an hour, and a smaller allowance per source network over a short window. Those values require abuse telemetry, support feedback, and compliance review. Keep windows and rule versions in configuration so a policy change is itself auditable.
Which failure boundary belongs in the design?
The dangerous boundary is between βthe request was acceptedβ and βthe message was sent.β A transport timeout does not prove that no message was sent, so blindly retrying can duplicate the code. The application needs durable request state and an idempotency key derived from the challenge, action, and policy version.
| Decision | Invariant | Failure behavior |
|---|---|---|
| Accept a challenge | All quotas pass | Persist accepted before dispatch |
| Repeat a request | Same idempotency key | Return existing challenge state |
| Transport timeout | Delivery is unknown | Mark delivery_unknown; do not mint another code automatically |
| Wrong or expired code | Challenge is unsuccessful | Record the reason without revealing which check failed |
| Successful code | Challenge is single-use | Authorize only the intended report action |
This is an exactly-once mindset applied to a network that cannot provide exactly-once delivery. The application can make its state transition once; it cannot make the carrier deliver an SMS once. Those claims must stay separate in the audit trail.
Consider a hypothetical timeout at the least convenient boundary. The worker has already handed the original code to the transport, but the acknowledgement never reaches the worker; meanwhile, the marketplace operator presses resend because the generated payout report is ready and the attachment is time-sensitive. Treating the timeout as proof of failure can create a second valid code, while treating it as proof of delivery gives the operator no controlled recovery path. The durable record should instead retain the original challenge, mark the dispatch outcome as unknown, and return that same state when the client repeats the same idempotency key. If policy permits a later resend, record it as a related attempt rather than silently overwriting the first one. This longer state history is intentional: reconciliation must be able to distinguish request acceptance, a dispatch attempt, transport acknowledgement, code verification, report authorization, and attachment delivery. Collapsing those events into a single sent Boolean saves a column but destroys the evidence needed to explain a duplicate message or an authorization dispute.
Ambiguity is data.
How do you implement the critical path in Node.js?
The following sketch keeps the policy decision and send command distinct. Reserve must be atomic in the backing store, and PutChallenge must enforce a unique key. The SMS adapter is generic because the security property lives around it.
type Challenge struct {
ID, AccountID, PhoneHash, Action string
CodeDigest []byte
IdempotencyKey string
ExpiresAt time.Time
Status string
}
type Limiter interface {
Reserve(context.Context, []string, time.Time) (bool, error)
}
func RequestOTP(ctx context.Context, accountID, phoneHash, requestKey string,
limiter Limiter, store Store, now time.Time) (Challenge, error) {
key := accountID + ":report-export:" + requestKey
if old, ok := store.FindByIdempotency(ctx, key); ok { return old, nil }
allowed, err := limiter.Reserve(ctx, []string{
"account:" + accountID, "phone:" + phoneHash,
"action:report-export",
}, now)
if err != nil { return Challenge{}, err }
if !allowed { return Challenge{}, ErrRateLimited }
code := randomCode()
challenge := Challenge{ID: newID(), AccountID: accountID,
PhoneHash: phoneHash, Action: "report-export",
CodeDigest: digest(code), IdempotencyKey: key,
ExpiresAt: now.Add(5 * time.Minute), Status: "accepted"}
if err := store.PutChallenge(ctx, challenge); err != nil {
return Challenge{}, err
}
if err := store.EnqueueSMS(ctx, challenge.ID, code); err != nil {
_ = store.MarkDeliveryUnknown(ctx, challenge.ID)
return challenge, ErrDeliveryUnknown
}
_ = store.MarkDispatched(ctx, challenge.ID)
return challenge, nil
}
In a Node.js service, the same sequence is a transaction-like application flow: normalize the phone number, derive a keyed digest for audit correlation, atomically reserve all buckets, insert a challenge with a unique idempotency key, and enqueue an outbox event. A worker sends the SMS and records a provider-independent result. Never put the raw OTP in logs, metrics, traces, or the report job payload.
A successful enqueue does not mean the recipient saw the message. A non-atomic get followed by set also turns a correct-looking limiter into a race under concurrent requests. Use an atomic script or primitive in a key-value store, or a conditional write or locked counter in a relational database.
What did I initially underestimate?
I initially treated the phone number as the main throttle key. That sounds sensible until a household or call center shares a number, while an attacker rotates numbers against one account. The correction is a matrix: account limits protect the user, phone limits protect the destination, source limits absorb automation, and a global circuit breaker protects the sending boundary.
Another trap is resetting every bucket after successful verification. Verification consumes the challenge and grants narrowly scoped report permission; it does not erase evidence of preceding requests. A delivery receipt is not proof of possession. Possession is established by a valid, unexpired, single-use code entered into the authenticated login context.
Keep the evidence.
How should the system be tested and operated?
Test the state machine under concurrency, not only the happy path. Two identical requests should produce one challenge and one effective send command. Two different requests should consume the correct independent quotas. A timeout followed by a retry should preserve the original challenge state and expose an operator-visible ambiguity, rather than silently creating a second authorization path.
Useful metrics are decisions and transitions: accepted requests, quota rejections by rule, duplicate idempotency hits, delivery-unknown outcomes, code failures, successful consumptions, and report authorizations. Keep phone numbers out of labels; sensitive dimensions belong in protected audit storage. Alert on changes in rejection ratios and delivery-unknown rates without revealing policy to the caller.
NIST SP 800-63B describes throttling as a verifier control and addresses failed authentication attempts. Local law, contracts, and the marketplace retention schedule determine what can be retained and for how long. SMS has known security limits, including reassignment and interception risk, so a high-risk administrative action may need a stronger authenticator rather than an ever-tighter quota.
The rejected option is a single IP-based fixed-window counter in the web process. It is useful as a coarse emergency brake, but it cannot distinguish shared networks from one account, disappears during process replacement, and cannot explain a transport timeout. It remains one defense layer. It is not an authentication design.
References
- https://pages.nist.gov/800-63-4/sp800-63b.html
- https://www.rfc-editor.org/rfc/rfc4226
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://nodejs.org/api/crypto.html
- https://redis.io/docs/latest/develop/programmability/eval-intro/
- 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.