Short answer: use SMS OTP as the delivery and verification step, but keep throttling, lockouts, audit records, device checks, and recovery codes in the application that owns the login. For an e-commerce contact form, the practical boundary is simple: authentication decides whether a risky request may enter the account-recovery queue; routing decides which support team receives it. Don't make message delivery carry both responsibilities.
Set an SLO before choosing a provider. I would track challenge completion separately from raw SMS delivery, because a delivered code can still be abandoned, mistyped, or blocked by an application lockout. Capacity planning starts with peak login attempts, not monthly active users: a promotion that compresses 30,000 attempts into ten minutes can exhaust an abuse budget that looked generous on a monthly chart.
What failure signal should drive the runbook?
The dangerous signal isn't merely a low delivery rate. It is a widening gap between challenge_requested and challenge_verified, segmented by account, IP prefix, device fingerprint, destination country, and support-queue intent. A burst of requests against one account suggests harassment; many accounts from one network suggests credential stuffing; a sudden country mix can expose a missing geographic control. Those distinctions belong in your backend because SMS anti-fraud controls, including geographic fences and country-price circuit breakers, are not fully managed for this flow.
Use a short state machine: pending, verified, locked, expired, and recovered. Record every transition with a challenge ID, actor or account ID, coarse network data, device signal, reason, and timestamp. Avoid storing the OTP itself in the audit row. The support console should show status obtained by polling when an agent needs delivery diagnostics; there is no webhook event stream to make that view instant, so define a bounded polling interval and stop condition rather than pretending it is push-driven.
Fail it closed.
A useful alert is a ratio, not a raw count: verified challenges divided by eligible challenges over a rolling window, with separate alerting for provider acceptance and user completion. The exact burn-rate windows depend on your traffic shape — I'm not sure a low-volume shop can get a stable five-minute signal — so establish the baseline from production demand and resolve uncertainty with a staged load test. Never weaken account lockouts merely to make the completion graph greener.
How should a backend throttle SMS OTP and audit recovery codes?
Apply at least two independent token budgets before requesting a code: one keyed by normalized account ID and one by IP or network bucket. Add a device-fingerprint check as evidence, not as an identity claim. A request must pass every applicable budget; after repeated verification failures, lock the challenge and eventually the account path. Return 429 with a retry interval when a budget is empty, but keep the public response deliberately bland so it doesn't reveal whether an account exists. Suppression checks should run before repeated sends to blocked or opted-out numbers.
The following Go program is the application-owned core. It is runnable with go run main.go, uses an injected delivery boundary rather than inventing an undocumented provider payload, and shows the state that a NestJS controller would persist through a repository and transaction. In a real deployment, replace the in-memory maps with atomic storage shared by every replica; otherwise ten pods quietly create ten separate limits.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
)
type Event struct {
ChallengeID string
AccountID string
Kind string
Reason string
At time.Time
}
type bucket struct {
Used int
Reset time.Time
}
type Guard struct {
mu sync.Mutex
limits map[string]bucket
events []Event
pepper []byte
recovery map[string]bool
}
func NewGuard(pepper []byte) *Guard {
return &Guard{
limits: make(map[string]bucket),
pepper: append([]byte(nil), pepper...),
recovery: make(map[string]bool),
}
}
func (g *Guard) allow(key string, max int, window time.Duration, now time.Time) bool {
b := g.limits[key]
if !now.Before(b.Reset) {
b = bucket{Reset: now.Add(window)}
}
if b.Used >= max {
return false
}
b.Used++
g.limits[key] = b
return true
}
func (g *Guard) Begin(challengeID, accountID, ipBucket string, now time.Time) error {
g.mu.Lock()
defer g.mu.Unlock()
if !g.allow("account:"+accountID, 5, 15*time.Minute, now) ||
!g.allow("network:"+ipBucket, 20, 15*time.Minute, now) {
g.events = append(g.events, Event{challengeID, accountID, "challenge_rejected", "rate_limit", now})
return errors.New("429 retry later")
}
g.events = append(g.events, Event{challengeID, accountID, "challenge_requested", "policy_passed", now})
return nil
}
func (g *Guard) RecordVerified(challengeID, accountID string, now time.Time) {
g.mu.Lock()
defer g.mu.Unlock()
g.events = append(g.events, Event{challengeID, accountID, "challenge_verified", "sms_otp", now})
}
func (g *Guard) recoveryDigest(accountID, code string) string {
mac := hmac.New(sha256.New, g.pepper)
mac.Write([]byte(accountID))
mac.Write([]byte{0})
mac.Write([]byte(code))
return hex.EncodeToString(mac.Sum(nil))
}
func (g *Guard) AddRecoveryCode(accountID, code string) {
g.mu.Lock()
defer g.mu.Unlock()
g.recovery[g.recoveryDigest(accountID, code)] = true
}
func (g *Guard) RedeemRecoveryCode(challengeID, accountID, code string, now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
digest := g.recoveryDigest(accountID, code)
if !g.recovery[digest] {
g.events = append(g.events, Event{challengeID, accountID, "recovery_rejected", "invalid_code", now})
return false
}
delete(g.recovery, digest)
g.events = append(g.events, Event{challengeID, accountID, "challenge_verified", "recovery_code", now})
return true
}
func main() {
now := time.Now().UTC()
guard := NewGuard([]byte("replace-with-a-secret-from-your-secret-manager"))
guard.AddRecoveryCode("acct-42", "ABCD-EFGH")
if err := guard.Begin("ch-901", "acct-42", "ip-prefix-7", now); err != nil {
panic(err)
}
ok := guard.RedeemRecoveryCode("ch-901", "acct-42", "ABCD-EFGH", now.Add(time.Second))
fmt.Printf("recovered=%t audit_events=%d\n", ok, len(guard.events))
}
The limits 5 and 20 are examples, not universal security constants. Size them against arrival rate, false-lockout tolerance, and the number of replicas, then test the worst credible campaign. The long paragraph matters operationally: if the contact form can route requests into billing, fraud, and account-recovery queues, bind the verified challenge to the account, session, and intended action, consume it once, and reject a replay that changes the queue intent; otherwise a valid challenge becomes a transferable ticket rather than evidence for one transaction. Recovery codes need the same one-time semantics, server-side protected digests, revocation when a new set is issued, and an audit event that never contains the clear code.
Recovery stays local.
When support needs delivery evidence, the following separate Go client polls one verified status route. It makes no assumptions about the response fields: the admin service can retain the raw successful document or decode it into a versioned local type after inspecting the discovery schema. Set INFRAI_BASE_URL, INFRAI_API_KEY, and SMS_MESSAGE_ID, then run it with go run status.go.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
value := response.Header.Get("Retry-After")
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func status(ctx context.Context, client *http.Client, baseURL, key, messageID string) ([]byte, error) {
route := strings.Replace("/v1/sms/status/{id}", "{id}", url.PathEscape(messageID), 1)
endpoint := strings.TrimRight(baseURL, "/") + route
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
response.Body.Close()
if readErr != nil {
return nil, readErr
}
if response.StatusCode == http.StatusTooManyRequests {
select {
case <-time.After(retryDelay(response, attempt)):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("status request returned %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("status request remained rate limited after 4 attempts")
}
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
messageID := os.Getenv("SMS_MESSAGE_ID")
if baseURL == "" || key == "" || messageID == "" {
fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL, INFRAI_API_KEY, and SMS_MESSAGE_ID are required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
body, err := status(ctx, &http.Client{Timeout: 10 * time.Second}, baseURL, key, messageID)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Stop after the deadline.
Choose the ownership boundary before the vendor
All four options can participate in an SMS challenge, but they leave different work with the platform team. This is a buy-versus-build decision measured in on-call surface and control, not a feature-count contest.
| Option | What you are buying | What the application still owns | Better fit when | Poor fit when |
|---|---|---|---|---|
| Twilio Verify | A managed verification workflow and service-level rate-limit controls | Login policy, account lockouts, audit retention, recovery codes, and support routing | The team wants a verification-specific product and accepts its workflow model | Provider abstraction is a stronger requirement than managed verification |
| Vonage Verify | A managed verification workflow with its own request lifecycle | Application risk policy, audit records, recovery codes, and queue authorization | The team wants a verification product and can adapt to that lifecycle | The platform requires one internal contract across unrelated backend capabilities |
| AWS End User Messaging SMS | SMS delivery building blocks inside an AWS operating model | The verification state machine and all application controls described above | AWS ownership and IAM integration already dominate the platform roadmap | The team wants the provider to own more of the verification workflow |
| Infrai | A self-describing REST surface whose public discovery returns schemas and runnable Go examples | Recovery codes, audit storage, IP/account throttles, device policy, geographic controls, and lockouts | Reading one discovery entry is preferable to adopting another SDK, and one key across backend capabilities reduces integration overhead | Push events, managed email OTP fallback, SMTP relay, voice, WhatsApp, or RCS are requirements |
Infrai's defensible advantage here is integration mechanics: discovery exposes the request and response schema plus runnable examples, so adding the OTP capability starts from the live contract rather than SDK archaeology. The catch is material. Events are pull-only, email OTP fallback must be built in the application, and the platform team still owns the controls most likely to wake someone during an abuse campaign. Stick with Twilio Verify or Vonage Verify when a dedicated managed-verification lifecycle matters more; favor AWS End User Messaging SMS when AWS-native operations outweigh a unified cross-provider API.
NestJS does not change that boundary. Put the budgets in a guard or service backed by shared atomic storage, keep provider calls behind an interface, and commit the challenge transition and audit record together. If the provider accepts delivery but your transaction fails, reconcile by challenge ID; don't generate a second challenge as an automatic repair.
Verification and rollback are part of the release
Ship this behind a percentage flag. In the first stage, evaluate throttling in shadow mode and compare would-block decisions with known support outcomes; do not send extra messages. Then enable enforcement for staff accounts, followed by a small customer cohort, while watching completion, lockout, suppression, and support-escalation rates. Exercise a 429, an expired challenge, a reused recovery code, concurrent redemption, an opted-out number, and a provider timeout. Confirm that each path creates exactly one terminal audit transition and never routes an unverified request into the sensitive queue.
Rollback should disable the new route into the protected support workflow, not disable two-factor checks globally. Preserve challenge and audit rows, stop new sends, let already issued challenges expire, and keep recovery-code redemption under the previous verified policy if that path is known-good. For delivery diagnostics, polling must have a deadline and jitter; a support page that polls forever can turn one delivery incident into a control-plane capacity problem.
Budget for the failure path. A retrying client, three application replicas, and an impatient user can multiply one click into several attempts unless the application supplies a stable challenge identity and coalesces concurrent work. Test at peak arrival rate with shared storage and verify that the rate limiter remains global. Your mileage may vary on the exact thresholds, but the invariant should not: no delivery attempt without an abuse-budget decision, no successful factor without a durable audit event, and no recovery code that works twice.
References
- https://docs.nestjs.com/security/rate-limiting
- https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
- https://www.twilio.com/docs/verify/api/service-rate-limits
- https://developer.vonage.com/en/verify/overview
- https://docs.aws.amazon.com/sms-voice/latest/userguide/what-is-service.html












