DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Node.js OTP Throttling: Delivery Guarantees for Logistics 2FA

A rate limiter for SMS OTP requests is a delivery-control system, not a single counter. The useful trade-off is strict enough to stop abuse while leaving a legitimate driver a recovery path when a message is delayed. For a Node.js logistics portal, I would enforce limits at three scopes: account, verified phone number, and network signal. I would also count attempts and sends separately. That distinction keeps a slow carrier from turning retries into an SMS storm.

How do you rate-limit SMS OTP requests in a Node.js 2FA flow?

Option Pick this when Main boundary
Fixed window You need a small, easy-to-audit baseline A boundary reset can create a burst
Sliding window You need a smoother per-identity policy It needs shared, expiring state
Token bucket You need controlled bursts with a steady refill Capacity and refill units need careful tuning
Distributed counter Several Node.js instances serve the login flow Clock, atomicity, and key expiry become part of the design

For a single process or a first test, a fixed window can be enough. Pick sliding-window state when the abuse pattern is bursty and the user experience matters. Pick a token bucket when a brief burst is legitimate but the long-run SMS rate must stay bounded. For production traffic across instances, the decision is less about the algorithm name than about atomic updates and expiry in the shared store.

The limits should be policy, not magic. A useful starting shape is one code request per identity every 30 seconds, five requests per hour per phone number, and a daily ceiling that triggers a support path. Those values are examples to validate against carrier behavior, fraud data, and the risk of the account. They are not universal defaults.

How should Node.js separate sending from verification?

Think of the flow as a narrow pipe: request OTP -> reserve a send quota -> create a short-lived challenge -> enqueue delivery -> verify the code. The receipt email after a settled logistics payment sits beside this pipe, not inside it. Both workflows need delivery telemetry, but a receipt must never be a reason to relax an authentication limit.

The counter is the easy part.

The critical rule is to reserve the quota before the provider call. Otherwise two concurrent requests can both pass a read-then-write check. Imagine a driver tapping “send again” while a browser retry is still in flight: two workers read the same remaining quota, both call the SMS service, and the dashboard records an innocent-looking pair of successful requests. A retry from the delivery queue can multiply that effect unless the send operation has an idempotency key. The reservation must be atomic in the shared store, and a failed provider call needs a deliberate policy: release the reservation only when a retry is safe, or keep it consumed to protect the carrier. The code below keeps the limiter behind an interface so the application can use a Redis-like store, a database transaction, or another implementation with the same atomic contract.

type LimitResult = {
  allowed: boolean;
  retryAfterSeconds: number;
};

type QuotaStore = {
  consume(key: string, limit: number, windowSeconds: number): Promise<LimitResult>;
};

const policies = [
  { suffix: "identity", limit: 1, windowSeconds: 30 },
  { suffix: "phone", limit: 5, windowSeconds: 60 * 60 },
  { suffix: "network", limit: 20, windowSeconds: 60 * 60 },
] as const;

async function canRequestOtp(
  store: QuotaStore,
  identityId: string,
  phoneHash: string,
  networkKey: string,
): Promise<LimitResult> {
  const keys = [
    `otp:${policies[0].suffix}:${identityId}`,
    `otp:${policies[1].suffix}:${phoneHash}`,
    `otp:${policies[2].suffix}:${networkKey}`,
  ];

  const results = await Promise.all(
    policies.map((policy, index) =>
      store.consume(keys[index], policy.limit, policy.windowSeconds),
    ),
  );

  const blocked = results.find((result) => !result.allowed);
  return blocked ?? { allowed: true, retryAfterSeconds: 0 };
}
Enter fullscreen mode Exit fullscreen mode

Do not use a raw phone number as a key. Normalize it first, then use a keyed digest with controlled access. Do not log the OTP, the full phone number, or a code that can be replayed. A challenge needs an expiry, a single-use state transition, and a maximum verification-attempt count. Rate-limit verification too; otherwise the send limiter protects the carrier while the guesser attacks the code.

What should the dashboard prove?

A 429 response is only the visible edge. Record counters for requests, allowed sends, blocked sends, delivery-provider accepts, delivery failures, verification successes, and verification lockouts. Add dimensions for policy scope and a coarse region, but avoid putting phone numbers, OTPs, or raw IP addresses into labels. High-cardinality labels make the dashboard expensive and can expose personal data.

The useful diagram in words is: request rate -> quota decision -> queue delay -> carrier acceptance -> handset delivery -> verification result. A fall in the last metric can come from a carrier delay, an expired challenge, or an attack. Those are different actions, so keep the stages separate.

Alert on ratios and persistence. For example, a sudden increase in blocked requests with stable successful verifications suggests abuse or an overly strict policy. A stable request rate with rising queue age points to delivery capacity. A high provider-accept rate with low verification success needs investigation, but it does not prove the SMS reached the handset.

For the logistics portal, trace one correlation ID across the payment-settled event, receipt dispatch, and authentication session, while keeping the OTP itself out of the trace. That lets an operator answer a practical question: did the user fail to authenticate, or did the message arrive after the challenge expired?

Where do common implementations break?

The first trap is an in-memory Map. It disappears on restart and each Node.js instance sees a different truth. The second is a non-atomic get followed by set; concurrent requests can both win. The third is returning a different message for an unknown account, which creates an account-enumeration oracle. Keep response timing and wording deliberately similar.

A limiter can also lock out a real user behind a shared office network. That is why network scope should be a backstop, not the only key. Conversely, phone-only limits can be evaded with many accounts. Use several signals, document their order, and give support a controlled recovery process with stronger identity checks.

This design does not guarantee delivery.

SMS carriers, handset state, and regional filtering remain outside the application. It also does not replace fraud detection, consent requirements, or a second authentication factor that is less dependent on a phone network. Test expiry boundaries, concurrent requests, restarts, clock skew, and queue retries before treating the counters as evidence.

Sources

Top comments (0)