DEV Community

DorianReed2186
DorianReed2186

Posted on

Password Resets: SMS API Alternatives Under OTP Abuse Rate Limits in US and EU

Short answer: for password-reset SMS alerts, choose an API by proving that a fresh OTP reaches a real handset before its short expiry while duplicate, replayed, and abusive requests are stopped before they create extra sends. The lowest quoted message price is secondary because a cheap request that arrives late, is retried twice, or gives an attacker unlimited attempts has failed the customer-support job.

The operational constraint changes the comparison. A password-reset code has a narrow useful life, so an aggregate delivery percentage hides the cases that matter: delivery inside the expiry window, for each country and carrier path, during both ordinary traffic and a burst. I would make that evidence the buying gate, then compare Plivo, Telnyx, Vonage, Twilio, or any alternative behind the same small adapter.

Don't start with a feature grid.

How should an SMS alert API handle OTP abuse across the US and EU?

Rate limiting needs more than one counter. A per-phone limit slows repeated sends to one recipient, but an attacker can rotate phone numbers. A per-account limit catches one authenticated account targeting many numbers, but it misses unauthenticated reset traffic. A per-IP limit helps with a noisy source, yet shared networks make it dangerous as the only control. Put all three ahead of the provider call, add a broader global circuit breaker, and keep the user-facing response deliberately vague so the reset endpoint doesn't become an account-discovery tool.

The OTP itself should be single-use, bound to the intended reset action, stored as a verifier rather than recoverable plaintext, and invalidated after a successful reset. Expiry and resend behavior are one policy: issuing a replacement should not leave a pile of simultaneously valid codes. The catch is that aggressive limits can block a legitimate person on a corporate NAT or a recycled phone number. When that risk is high, use a step-up challenge or a non-SMS recovery path instead of silently relaxing every limit.

This is where “cheapest” gets slippery. The useful cost unit is a completed, timely reset, including duplicate suppression, retries, support contacts, and regional sender requirements — not the advertised cost of one API request. Prices and country rules change, so I wouldn't freeze a universal winner into application code. Your mileage may vary sharply by destination mix.

Make one send decision, then isolate the carrier adapter

The application should own eligibility, OTP state, idempotency, and routing policy. The SMS provider should receive an already-approved command. That boundary lets the same test suite exercise every candidate without letting provider-specific response fields leak into password-reset logic.

Here is the focused shape. The numbers are example application policy, not a claim about any provider's defaults.

type ResetMessage = {
  requestId: string;
  accountId: string;
  phoneE164: string;
  locale: "en-US" | "en-GB" | "de-DE";
  otp: string;
  expiresAt: Date;
};

type SendResult = {
  providerMessageId: string;
  acceptedAt: Date;
};

interface SmsAdapter {
  sendPasswordReset(message: ResetMessage): Promise<SendResult>;
}

type LimitInput = Pick<ResetMessage, "accountId" | "phoneE164"> & {
  sourceIp: string;
};

async function requestPasswordReset(
  input: LimitInput,
  message: ResetMessage,
  sms: SmsAdapter,
): Promise<{ accepted: true }> {
  const allowed = await resetLimiter.consume({
    account: { key: input.accountId, limit: 3, windowSeconds: 900 },
    phone: { key: input.phoneE164, limit: 3, windowSeconds: 900 },
    ip: { key: input.sourceIp, limit: 10, windowSeconds: 900 },
  });

  if (!allowed) {
    return { accepted: true };
  }

  await outbox.enqueueOnce(message.requestId, message);
  return { accepted: true };
}
Enter fullscreen mode Exit fullscreen mode

The intentionally boring response matters: callers get the same acknowledgment whether the account exists, a limit fires, or a send is queued. The outbox key prevents two web workers from turning one reset request into two messages. A worker can then check expiresAt before calling the adapter; an expired command is discarded rather than delivered as a confusing dead code.

Keep the provider call out of the web request. An asynchronous worker absorbs transient network latency, but don't retry blindly. Retry only outcomes your adapter classifies as safe, preserve the same internal request ID, and stop once the remaining OTP lifetime is too short to be useful. Fast failure wins.

Test the expiry window, not an average delivery rate

A useful experiment has a fixed dataset: the same reset template, expiry, destination sample, time bands, and success definition for every candidate. Separate US and EU results; don't blend them into a global mean. Record when the application accepted the reset, when the provider accepted the message, when a delivery event arrived, and when a test handset actually received it. Provider acceptance is evidence of handoff, not proof that the person could use the code.

The simple approach is to send a small daytime batch and count accepted requests. It fails because it cannot expose tail latency, duplicate delivery, stale arrival, or abuse behavior. A better run includes normal sends, a controlled resend, the same requestId submitted twice, a burst that should hit each application limit, an expired queued item, and a deliberately delayed callback. For one test case, enqueue a code with five minutes of useful life, submit its request ID twice, and hold the delivery event until after expiry. The adapter should produce one provider submission, the handset log should show no duplicate, and the delayed event should remain attached to the original attempt without making the code valid again. That single case crosses the web handler, limiter, idempotency store, queue, adapter, event parser, and reset state machine, which makes it more informative than a large batch that checks only provider acceptance. No production users belong in this experiment; use controlled accounts and numbers you are authorized to test.

One request, one send.

Measure these outcomes with raw counts and percentiles rather than one composite score:

Signal Decision it supports
Handset receipt before expiry Whether the route completes the reset job
p50, p95, and p99 receipt time by region Whether the tail fits the chosen expiry
Duplicate receipts per unique request Whether retries and idempotency interact safely
Blocked sends by account, phone, and IP Whether abuse controls fire at the intended layer
Delivery-event lag and unmatched events Whether operations can trust provider feedback
Completed resets per attempted send Whether spend produces a useful outcome

I'm not sure a short pre-production run can predict a carrier's behavior during every future traffic spike. It can't. What resolves that uncertainty is a small shadow evaluation followed by ongoing regional monitoring, with the same definitions retained after launch. Do not quietly tune the expiry separately for each vendor during the trial; that would compare two systems and two policies at once.

Treat callbacks as untrusted, reordered input

Delivery callbacks cross a public boundary. Authenticate them using the mechanism documented by the provider, parse them into a provider-neutral event, and reject malformed timestamps or unknown message IDs. Events may reach your handler after the local record changes, so model state transitions explicitly instead of overwriting a row with whichever callback arrived last.

Store enough correlation data to answer one support question quickly: “Did this reset code reach the handset while it was valid?” Avoid putting the OTP, reset token, or full message body in logs. Restrict access to phone numbers, define retention, and make deletion behavior part of the design. US and EU traffic may also require different sender setup and operational review; verify current destination requirements with each shortlisted provider before launch rather than assuming one configuration travels everywhere.

A callback outage inside your own stack is also a testing concern. Replay a signed fixture against the handler, confirm duplicate events are harmless, and alert on a growing gap between accepted sends and terminal outcomes. A dashboard of provider acceptance alone is comforting and incomplete.

Choose the boundary before choosing the provider

The comparison should end with a decision record, not a ranking. List the tested countries and carriers, message expiry, sample size, observation window, abuse policy, callback assumptions, and the threshold each candidate had to meet. Then document the non-delivery constraints that could reverse the choice: sender-registration work, data handling, support response, contract terms, and the engineering cost of maintaining another adapter.

Stick with a current provider when it already clears the expiry-window target and migration would add more operational risk than it removes. Choose a regional route or a second provider when a measured destination segment misses the target and the added routing complexity has an owner. SMS is not suitable as the only recovery method when users may lack reliable mobile coverage, cannot receive the selected route, or need a higher-assurance recovery process; offer a separately secured recovery channel in those cases.

Before copying this architecture, measure one thing first: the distribution of time from reset request to usable code for the actual US and EU destinations you serve. It determines whether queueing, retry, fallback, and the OTP lifetime form a coherent system. Everything else is negotiation.

References

Top comments (0)