Short answer: put recipient suppression ahead of every SMS OTP send, keep resend and status polling behind your own login API, and let only a successful code check authenticate the user.
For a developer-tools SaaS serving the US and EU, the smallest useful 2FA integration is not a bare send() call. It is one challenge record with a destination fingerprint, an expiry, a resend counter, and a provider-neutral delivery state. That boundary keeps invalid recipients from consuming repeated attempts and keeps a carrier status from being mistaken for proof of login.
The catch is that this design favors low weekly maintenance over maximum channel flexibility. It is not suitable when an account requires phishing-resistant authentication, when SMS cannot meet the product's risk policy, or when country-specific messaging review has not been completed. In those cases, use a stronger authenticator or do not launch SMS in that market yet.
What should a US and EU app require before SMS OTP status polling?
An SMS delivery outcome and an OTP verification outcome answer different questions. delivered says something about transport. Only a successful code check says the current challenge was completed. A useful internal model keeps those facts separate, because a resend can create another message but must never create another independent login session.
Invalid-recipient evidence belongs at the sending boundary. Normalize a phone number with a maintained library, reject impossible input before calling a delivery service, and look up active suppression for a keyed destination fingerprint. Do not treat the calling code as proof of residence, consent, or legal eligibility. A US number used in the EU is still just a number.
Suppression reasons need different behavior. A permanent invalid-recipient result can block later OTP sends under a reviewed retention policy. A user opt-out also blocks sends, but it should remain distinguishable for audit and support. A temporary delivery condition closes or delays the current attempt; it should not permanently poison the destination. This distinction is easy to skip in a demo and expensive to reconstruct after support tickets begin — exactly the kind of undifferentiated work that steals revenue-producing hours from a one-person product.
Temporary is not terminal.
The same principle applies to recovery email. A hard-bounced address should enter an email suppression path rather than receive repeated recovery messages. Google's sender guidelines emphasize authentication, wanted mail, and keeping spam rates low; they are a useful baseline for the email side of an account-recovery system, even though they do not define SMS OTP behavior.
Implementation log: the smallest working TypeScript policy
Keep provider details behind one interface. The application needs stable meanings, not every downstream status string. It also needs atomic writes: two resend requests that arrive together cannot both pass the same cooldown check, and two successful code submissions cannot both consume a stale challenge.
type DeliveryState =
| "pending"
| "delivered"
| "temporarily_unavailable"
| "invalid_recipient"
| "expired"
| "verified";
type ProviderDelivery = {
reference: string;
state: "pending" | "delivered" | "temporary_failure" | "invalid_recipient";
};
interface VerificationTransport {
start(destination: string): Promise<ProviderDelivery>;
getDelivery(reference: string): Promise<ProviderDelivery>;
check(reference: string, code: string): Promise<boolean>;
}
type StoredChallenge = {
id: string;
accountId: string;
destinationHash: string;
providerReference: string;
createdAtMs: number;
lastSentAtMs: number;
resendCount: number;
verifiedAtMs?: number;
};
This is an application contract, not a claimed vendor schema. The adapter translates a service's documented states into the six states above. The browser receives the challenge ID, sanitized state, expiry, and next allowed resend time. It never receives the normalized destination, downstream reference, or OTP value.
Here is the policy core I would ship first. The exact limits are product policy examples rather than security standards; traffic patterns, abuse evidence, and the chosen delivery service should determine the deployed values.
const RESEND_COOLDOWN_MS = 30_000;
const CHALLENGE_TTL_MS = 5 * 60_000;
const MAX_RESENDS = 2;
function mayResend(
challenge: StoredChallenge,
delivery: DeliveryState,
suppressed: boolean,
nowMs: number,
): { allowed: boolean; reason: string } {
if (suppressed) return { allowed: false, reason: "recipient_suppressed" };
if (challenge.verifiedAtMs) return { allowed: false, reason: "already_verified" };
if (nowMs - challenge.createdAtMs >= CHALLENGE_TTL_MS) {
return { allowed: false, reason: "challenge_expired" };
}
if (challenge.resendCount >= MAX_RESENDS) {
return { allowed: false, reason: "resend_limit" };
}
if (nowMs - challenge.lastSentAtMs < RESEND_COOLDOWN_MS) {
return { allowed: false, reason: "cooldown" };
}
if (delivery === "invalid_recipient") {
return { allowed: false, reason: "recipient_suppressed" };
}
return { allowed: true, reason: "ready" };
}
The resend update should compare the stored counter and timestamp in the same transaction that records the new attempt. One request wins. A concurrent loser can receive 409; a rate-limited caller can receive 429 with a bounded retry hint. Those codes describe the app's own API, not any delivery provider.
Code checks need expiration, bounded guesses, single use, and secret-free logs. If the application compares codes itself, use a constant-time comparison. If a managed verification service checks them, the adapter should expose only success or failure. On success, consume the challenge in the same transaction that creates the authenticated session.
No second session. No ambiguity.
Governance for delivery evidence and regional rollout
The browser should poll the application's status endpoint, never the delivery provider directly. Credentials stay server-side, authorization is consistent with the pre-login session, and provider state vocabulary cannot leak into the UI. Polling every two seconds may be a reasonable starting behavior for a small app, but the server must enforce its own read limit because background tabs and hostile clients won't respect a browser timer. Add jitter, pause when the page is hidden, and stop after verified, invalid_recipient, or expired. Status polling is reconciliation, not authentication: a delivered state may improve UI copy, but it cannot advance the login. A webhook can update the same challenge record when the chosen provider supports delivery events; authenticate it using the provider's documented mechanism, deduplicate events, and make repeated delivery safe. Polling can then reconcile delayed events without becoming a second source of truth. A resend is also a state transition, not a fresh start. It retains the original challenge ID, increments one bounded counter, and preserves one audit trail. If delivery is still pending after the cooldown, product policy can permit another send; if the destination has permanent invalid-recipient evidence, it cannot. I'm not sure one retention period fits every US and EU deployment. The answer depends on the product's actual messaging purpose, countries, contracts, and reviewed data policy. CTIA's messaging interoperability and compliance material is relevant to US business messaging practices. EU coverage should not be inferred from a provider's global marketing label; verify the required countries, sender identity, consent flow, opt-out handling, and data terms during evaluation.
Ship weekly. Do the country review first.
Test the policy without sending real SMS. A fake transport should produce delayed delivery, temporary failure, invalid-recipient evidence, a duplicate event, delivery after expiry, concurrent resend requests, and two simultaneous correct-code submissions. Then run a small end-to-end set through handsets in each supported region. A synthetic number can test adapter wiring, but it cannot establish carrier delivery or sender presentation on a real device.
For observability, record transitions and durations rather than secrets. Count challenges created, checks completed, resends requested, destinations suppressed, and time spent pending by destination country. Keep raw phone numbers, codes, and unrestricted provider messages out of logs and metric labels. Compare changes with the product's own baseline; a single global delivery figure can hide a regional shift.
Compare integration effort with one exit test
Twilio Verify, Vonage Verify, and Sinch Verification are real managed verification products worth including in a neutral evaluation set. Their supported regions, channels, state vocabularies, and account requirements can change, so current documentation and contracts must settle those details. The table is an evaluation plan, not a ranking.
| Option | Integration surface | Initial effort | Suitable when | Main limitation to verify |
|---|---|---|---|---|
| Twilio Verify | Managed verification API with documented SDK options | Build one adapter and event mapping | The team wants the code check and delivery workflow managed together | Required country, sender, status, and data settings remain service-specific |
| Vonage Verify | Managed verification API with documented SDK options | Build the same adapter contract and mapping tests | The application needs another managed candidate in the same bake-off | Status terms and regional setup must be mapped from current documentation |
| Sinch Verification | Managed verification API with documented SDK options | Repeat the fixed test script through one adapter | The evaluation needs a third managed implementation | Coverage, event behavior, and account configuration need current validation |
Use the same destinations, challenge cases, and acceptance notes for all three. Measure time to a verified handset test, effort to authenticate events, clarity of terminal invalid-recipient evidence, behavior under duplicate requests, required US/EU setup, operational record access, and adapter removal. Don't embed an ambiguous provider state in the shared domain merely because one candidate exposes it. Store extra evidence privately when useful and keep the public state machine conservative.
The exit test matters most: replace the fake transport with each adapter while leaving the coordinator and browser unchanged. If that requires rewriting resend policy or verification semantics, the abstraction is leaking. Integration effort includes that future rewrite, not just today's install command.
This is where the revenue-per-hour lens helps. Outsource message delivery and code verification when a managed service fits the risk policy, but own suppression, challenge authorization, audit semantics, and the adapter boundary. Stick with a self-managed delivery path only when control or contractual requirements justify its larger operational surface. A one-person SaaS should not become a messaging operator by accident.
What changes at scale
At higher volume, move challenge expiration and suppression lookup into storage with atomic conditional updates, process authenticated delivery events through an idempotent queue, and add per-account plus per-destination abuse controls. Keep polling as a bounded reconciliation path. None of those changes should alter the browser's small state model.
The trade-off is more machinery: queues, event retention, replay tooling, and regional operational review all consume time. Add them when observed traffic or risk requires them, not to make the first version look sophisticated. For the solo founder, the durable result is plain: suppress before send, make resend atomic, treat delivery as evidence, and let verification alone complete login.
Top comments (0)