DEV Community

nathanielbrooks0360
nathanielbrooks0360

Posted on

Contact-Queue Access Hardening — SMS OTP, Throttle Policy, Audit Records, Recovery Codes

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))
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)