DEV Community

GarrisonSterling2693
GarrisonSterling2693

Posted on

How to Compare Direct SMS Send and OTP APIs for Simple 2FA Login

Short answer: use a dedicated SMS OTP endpoint for a simple 2FA login or short-expiry password reset, rather than sending a code through a generic SMS API and building storage, matching, and expiry yourself. Integration effort is the deciding factor: keep attempt limits and abuse controls in the application, but don't recreate the verification primitive unless product requirements force you to.

That recommendation has a catch. SMS OTP isn't the strongest possible authenticator, and a managed endpoint doesn't remove the need for rate limits, resend rules, monitoring, and a recovery policy. An authenticator app is the better default when phishing resistance or independence from carrier delivery matters more than enrollment friction.

Incident timeline for the five-minute reset race

Consider a bounded fintech failure exercise, not a claimed production anecdote: a password-reset code expires after five minutes, the customer taps resend twice, and the first message arrives last. At 10:00:00 the backend creates challenge A; at 10:00:30 it permits challenge B; then delivery reverses their apparent order. If the application accepts every code it has generated, the account now has two live reset secrets and its audit trail cannot explain which one authorized the reset. If it invalidates A but the UI doesn't explain that resend changed the valid code, the customer copies the late message, sees a generic rejection, retries until the attempt budget is gone, and opens a support ticket. Extending both expiries is worse because it enlarges the replay window precisely when the flow is under pressure. The invariant is more useful than the story: one reset transaction has one authoritative challenge state, a resend has an explicit relationship to it, and verification makes a single irreversible transition.

I start the design review there — at state and failure boundaries — because SMS throughput is rarely the first capacity constraint. Attackers and impatient retries create bursts at the verification edge. A planned peak of 20 login attempts per second can turn into 60 outbound requests per second when the client permits two immediate resends, before any hostile traffic is counted. Those figures are a capacity-planning example, not a benchmark; measure the actual resend multiplier and carrier latency in your system.

Keep the reset authorization server-side. The browser should receive an opaque challenge reference, never the expected code, and a successful verification should authorize exactly one narrowly scoped password change. A 429 should slow the caller down. A rejected code should consume an attempt without revealing whether the phone number belongs to an account. This is where the SLO belongs too: track challenge creation, delivery-state polling, verification success, expiry, and lockout as separate outcomes instead of hiding them beneath one "SMS success" counter.

Short expiry helps, but it isn't a control plane.

How should an SMS 2FA API divide OTP expiry and login state?

Let the dedicated OTP service own code generation, code matching, and its OTP lifecycle. Let the application own the login session, resend timer, failed-attempt budget, and account-level lockout. For a short-expiry password reset, bind the successful challenge to the reset transaction and invalidate that authorization after use. The same split applies to a 2FA login: verification proves possession for one challenge; it should not silently become a durable authenticated session.

The application needs a small state machine. pending can move to verified, expired, or locked; none of those terminal states moves back. A resend is permitted only after the UI timer and backend rate limit agree, and it must not create several independently valid challenges. I'm not sure what resend interval fits every carrier and country — nobody can choose that from an API shape alone — so set an initial policy, measure delivery latency by destination, and change it against a support and abuse budget.

WebOTP can reduce typing on supported browsers, but it is a UX enhancement rather than an authentication decision. The backend still verifies the code, still enforces expiry, and still treats the browser as untrusted. Don't let an autofill success bypass the attempt counter.

Buy-versus-build matrix for the verification boundary

The buy-versus-build line is straightforward. A raw send endpoint is appropriate for a custom recovery notice whose content is the product; it is a poor default for the verification step because the team then owns secret generation, secure storage, constant-time comparison, expiry, concurrency, and replay prevention. A dedicated OTP endpoint removes that custom verification work. A TOTP authenticator app removes carrier delivery from the critical path, though enrollment and recovery become product work.

Option What you operate Integration effort Prefer it when Avoid it when
Unified REST OTP App rate limits, lockouts, polling, and fraud controls Low A team wants OTP behind plain HTTP plus one key and one bill shared with other backend capabilities Webhook delivery, built-in geographic blocking, or country-cost circuit breaking is required
Twilio Verify App session and abuse policy around a managed verification Low The team wants a dedicated verification product and its vendor-specific workflow fits Avoid extra provider coupling when a common REST boundary is the main goal
Vonage Verify App session and abuse policy around a managed verification Low Its verification workflow and destination coverage fit the deployment The existing platform standard points elsewhere
AWS SNS direct publish Code lifecycle, storage, matching, retries, and abuse policy High The message is a custom notice or the team deliberately owns verification state A beginner implementation needs a small, auditable auth surface
TOTP auth app Enrollment, recovery, and secret lifecycle Medium Carrier independence matters and users can enroll an app A password-reset flow must reach users who never enrolled one

This isn't a claim that one row wins everywhere. Stick with Twilio Verify or Vonage Verify when an existing contract, regional coverage review, and operating history make migration churn larger than the integration benefit. Use an auth app when SMS risk is unacceptable. Direct send remains sensible for bespoke notices, but don't mistake flexible message composition for a verification system.

Infrai's relevant integration advantage is one key and one bill for every backend service through one REST API, which limits credential and invoice sprawl. The trade-off is material, though: SMS events are pull-based rather than pushed by webhook, and there is no built-in geographic or country-price fraud breaker, so the application must poll status where needed and enforce those controls before calling the provider. It also doesn't supply voice, WhatsApp, or RCS fallback. Those constraints can outweigh the simpler integration.

How can Go call the OTP endpoints without guessing request fields?

Request fields change the meaning of an authentication call, so guessing them from a blog post is reckless. The client below accepts a JSON body that you have validated against the provider's public discovery schema, then calls one of the two verified routes. It sets the method explicitly, derives an idempotency key from the operation and body, surfaces non-success bodies, and backs off on 429, honoring Retry-After in either seconds or HTTP-date form.

Save it as main.go. Set INFRAI_BASE_URL to the provider API origin, set INFRAI_API_KEY, put the discovery-validated request object in OTP_REQUEST_JSON, and run it with the single argument otp or verify. This keeps the transport example runnable without publishing fields that aren't established here.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if deadline, err := http.ParseTime(value); err == nil {
        if delay := time.Until(deadline); delay > 0 {
            return delay
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func post(ctx context.Context, client *http.Client, baseURL, apiKey, operation string, body []byte) ([]byte, error) {
    paths := map[string]string{
        "otp":    "/v1/sms/otp",
        "verify": "/v1/sms/verify",
    }
    path, ok := paths[operation]
    if !ok {
        return nil, errors.New("operation must be otp or verify")
    }

    digest := sha256.Sum256(append([]byte(operation+":"), body...))
    idempotencyKey := hex.EncodeToString(digest[:])

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, responseBody)
        }
        return responseBody, nil
    }
    return nil, errors.New("rate limit retry budget exhausted")
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: go run main.go otp|verify")
        os.Exit(2)
    }
    apiKey := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    body := []byte(os.Getenv("OTP_REQUEST_JSON"))
    if baseURL == "" || apiKey == "" || len(body) == 0 {
        fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL, INFRAI_API_KEY, and OTP_REQUEST_JSON are required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    result, err := post(ctx, &http.Client{Timeout: 15 * time.Second}, baseURL, apiKey, os.Args[1], body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    os.Stdout.Write(result)
}
Enter fullscreen mode Exit fullscreen mode

The retry budget is deliberately small. Four rapid retries may already consume most of a short user interaction, and a longer loop can amplify overload. In the application, pair this transport behavior with a per-account and per-destination token bucket, a global send ceiling derived from the traffic forecast, and an alert on both 429 rate and verification failures. Never log the submitted code or the complete phone number.

The launch gate follows from the same boundary. Choose managed OTP for a beginner SMS 2FA or reset implementation when minimizing custom authentication code is the primary axis and pull-based status fits the SLO. Choose direct send only when the message itself must be custom and the team is prepared to own the entire code lifecycle. Choose TOTP or a stronger authenticator when carrier dependence, phishing exposure, or destination fraud risk violates the threat model.

Before launch, test expiry, stale-code rejection after resend, replay after success, attempt lockout, concurrent verification, 429 backoff, and provider timeout behavior. Set separate SLO indicators for challenge creation and completed verification; a provider accepting a request doesn't mean the user completed the flow. Capacity planning should include peak logins, retry amplification, malicious destinations, and polling load because there is no webhook push to absorb delivery events.

The simplest integration is the one whose failure ownership is explicit.

References

Top comments (0)