Short answer: treat SMS OTP as a delivery workflow with regional policy inputs, not as a single send call. Register the sender where required, keep routes and message identity consistent, and make the login flow tolerate delay, rejection, and duplicate delivery.
For an edtech signup, that means the verification link is not complete when the application hands an SMS to a provider. It is complete when the learner can use one valid token, and the system can explain what happened to every other attempt.
What actually fails between “send” and “login”
The useful mental model is a chain: application, messaging provider, carrier route, handset, and finally the login verifier. Each hop has a different failure mode. A provider can accept a request while the carrier filters the message later. A handset can receive two messages after a delayed first attempt. A user can request a new code, then enter the older code from a notification that arrived out of order. That distinction matters when an on-call dashboard reports an accepted send: the event says something about one hop, not about the learner's ability to finish 2FA login.
That is the first trap.
Carrier filtering is an anti-fraud control, so it is not something an application can reliably fix by retrying faster. Sender identity, traffic pattern, destination country, message content, and local registration requirements all affect the path. US application-to-person traffic has a registration and compliance dimension; the A2P 10DLC documentation is a concrete example of why sender registration belongs in the launch checklist, not in a post-incident scramble. EU delivery is not one uniform carrier policy either. Country and carrier behavior can differ.
Shared routes add another operational wrinkle. They can be useful for reach, but the application usually has less control over which path carries a message and how quickly a carrier accepts it. A dedicated sender may provide clearer ownership and observability, while a shared route may be adequate for lower-risk or lower-volume traffic. Neither label is a substitute for delivery data.
The metric that matters is not merely “API request accepted.” Record provider acceptance, carrier delivery status when available, time to delivery, expiry state, and the user's successful verification. Keep those states separate. Otherwise a green send metric can hide a very red login funnel.
How do carrier filtering, sender registration, and shared routes affect SMS OTP for 2FA login?
They change the probability and timing of delivery, but they should not change the security contract. The contract is simple: a token is short-lived, bound to the intended account and purpose, single-use, and checked server-side. The delivery channel is best-effort transport around that contract.
For a verification link, store a digest of the token rather than the token itself. Give each signup attempt a record with an expiry time and a consumed timestamp. When a learner asks for another message, invalidate the previous token or make the version explicit. Rate-limit both the account and the destination number. This protects the service from accidental notification storms and makes carrier filtering less likely to be triggered by your own retry loop. In practice, I want the signup record, delivery events, and verification result joined by one correlation ID, because a support engineer should be able to distinguish a rejected send from a late handset notification without asking the learner to forward a code. The dashboard should also show the active token version, so a resend is an intentional state transition rather than an invisible second attempt. Those records are useful during a route change, a carrier escalation, and a replay of a duplicate-delivery incident; they turn a vague complaint into a bounded timeline.
Here is the important part of a Go verifier. The repository and delivery adapter are intentionally generic: the security decision must stay independent of whichever route delivered the message.
package otp
import (
"crypto/sha256"
"crypto/subtle"
"time"
)
type Attempt struct {
AccountID string
Purpose string
Digest [32]byte
ExpiresAt time.Time
Consumed bool
}
func digest(code string) [32]byte {
return sha256.Sum256([]byte(code))
}
func Verify(a Attempt, accountID, purpose, code string, now time.Time) bool {
if a.AccountID != accountID || a.Purpose != purpose || a.Consumed {
return false
}
if !now.Before(a.ExpiresAt) {
return false
}
got := digest(code)
return subtle.ConstantTimeCompare(got[:], a.Digest[:]) == 1
}
The database transaction around Verify must atomically mark a successful attempt as consumed. That detail is where duplicate deliveries become a security test instead of a support ticket: two requests may race, but only one transaction should win. Log an internal attempt ID, outcome, country, sender configuration, and provider status. Do not log the raw code or put it in a URL query string.
Which sender and routing design fits an edtech signup?
Start with the countries and carrier mix your learners actually use. Ask the messaging provider for country-specific sender registration requirements, delivery-status semantics, throughput limits, and escalation ownership. Put those answers in a runbook. “We can send there” is not enough detail for an on-call engineer.
The decision is usually a trade-off:
| Design choice | Strength | Cost or limitation | Prefer it when |
|---|---|---|---|
| Registered local sender | Clearer identity and compliance ownership | Registration takes coordination and can vary by market | A country is a major signup market |
| Shared route | Broad reach with less sender setup | Less control over path and potentially less predictable timing | Traffic is modest and delivery evidence is good |
| SMS plus fallback channel | Gives a delayed message another path | More product and abuse controls to operate | Learners can use email or an authenticator |
| One active token per attempt | Keeps the verifier simple and auditable | A late message can be confusing to the user | Security and supportability outrank convenience |
The catch is that SMS is not suitable as the only recovery path for every learner or every threat model. It depends on a reachable phone number and carrier behavior outside your control. Keep an email link, authenticator app, or support-reviewed recovery process when the account risk and user population justify it. Stick with a simpler SMS flow when the signup is low risk, the target markets have measured delivery, and the fallback is clear.
What should the runbook measure after launch?
Build a dashboard that can answer one question without a data hunt: “Did this learner fail because we rejected the request, the route delayed it, the carrier filtered it, or the verifier rejected the token?” Break the answer down by country, carrier when available, sender, route, application release, and retry count.
Alert on changes in successful verification rate and delivery latency, not on every individual undelivered message. Sample a small set of synthetic signups only where policy and consent permit it. During an incident, freeze broad retry increases. Retrying a filtered message can create more of the traffic pattern that caused the filter.
I have been paged for missed jobs and duplicate deliveries. The postmortem lesson is the same here: an ambiguous state is more expensive than a failed state. Keep a durable event trail, give support a correlation ID, and make the UI say whether the learner should wait, request a new code, or use the fallback. Your mileage may vary by country; carrier telemetry is the evidence that settles the argument.
One more practical check: test registration and sender changes before a campaign or semester launch, then test expiry, resend, delayed delivery, duplicate delivery, and concurrent verification in staging. A successful happy-path test proves very little about a route under filtering pressure.
References
- Amazon SES official documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Twilio US A2P 10DLC compliance documentation: https://www.twilio.com/docs/messaging/compliance/a2p-10dlc













