DEV Community

robertmiller4179
robertmiller4179

Posted on

Go SMS OTP and Email Verification Fallback for Reliable US/EU Login 2FA

Short answer: use managed SMS OTP as the primary login 2FA path in the US and EU, then keep a self-built email verification code as a deliberate fallback rather than an automatic retry.

That decision is about delivery reliability and operational ownership, not a claim that SMS is universally more secure or always cheaper. SMS exposes purpose-built OTP issue and verification operations. Email does not provide a managed OTP operation in this setup, so the application must own code generation, hashed storage, expiration, retry limits, single use, and the message template. Both channels lack webhook events, which means the fallback controller learns outcomes by polling. Budget for that lag before promising an instant channel switch.

The runbook rule is blunt: one login attempt, one active challenge, and one state machine that can prove which channel is authoritative.

What should drive an SMS OTP versus email verification choice for login 2FA?

Start with the failure you are trying to contain. A managed SMS OTP path removes authentication-specific machinery from the application, while an email fallback puts that machinery back in your database and worker code. That makes SMS the easier primary implementation here. It does not make SMS a free pass: geographic fencing, country-level price circuit breakers, resend throttles, and abuse detection still belong in business logic. Delivery reliability includes refusing traffic that looks like pumping, not merely handing every request to a carrier.

Email is useful when the user cannot receive the text or when the account recovery policy explicitly permits a second channel. The catch is that mailbox delivery has its own reputation and authentication concerns, and a successful send request is not proof that the recipient saw the code. For a B2B SaaS system that already handles bounces, an invalid-recipient result should add the address to suppression and close the email branch. Do not keep retrying a recipient known to be invalid. Google also expects senders to follow its sender guidelines; the fallback is still production email, even if each message contains only six digits.

Security changes the answer at the edges. SMS OTP is not suitable when the threat model rejects phone-network recovery or requires a phishing-resistant factor. In that case, neither SMS nor an emailed code is the right primary factor. Stick with a stronger authenticator chosen by that security policy. Within the narrower choice in this article, email is fallback material because it adds more application-owned state without removing the polling constraint.

Cost is a guardrail, not the selection argument. SMS message segmentation can turn one logical message into multiple billable segments, especially when Unicode changes encoding, so keep the copy short and test the exact template. More important, enforce allowed countries and a country-level spend breaker before send. There is no tag-aggregated cost-report API here to rescue a weak control loop after the fact.

Pick the contract before the provider

The practical options are not interchangeable. The evidence reviewed here is sufficient to make the architecture decision, but it is not a current benchmark of every vendor's latency, regional reach, or total cost. I'm not sure those dimensions can be ranked honestly without a workload-specific test using the same destination mix. Your mileage may vary.

Option What it changes in this runbook When to choose it When not to choose it
Unified REST platform Managed SMS OTP and verification share a stable contract while email OTP remains application-owned. Choose it when less vendor-specific integration code matters across the backend. Avoid it when webhooks, SMTP relay, voice, WhatsApp, or RCS are required.
Twilio Verify Treat it as a separate candidate and validate its current API, regional delivery, abuse controls, and pricing against the same test plan. Choose it when its verified behavior wins your destination-specific test. Do not choose from brand recognition alone.
Amazon SNS Treat it as another independently integrated SMS candidate, with the same country and failure-mode checks. Choose it when it fits an existing AWS operating model and passes the delivery test. Avoid assuming an AWS account removes application-level abuse controls.
Vonage Verify Evaluate it with the identical OTP acceptance, resend, and destination matrix. Choose it when its current service contract matches the required regions and controls. Do not mix its result semantics into a supposedly vendor-neutral state machine.
Self-built email The application owns generation, storage, expiration, attempt limits, templates, bounce handling, and suppression. Use it as a controlled fallback for an already verified email address. Do not make it the easy-looking default; the hidden work is authentication state and email operations.

Infrai puts managed SMS OTP and verification behind one key and a self-describing REST API; Go can call it over plain HTTP without an SDK, and the provider behind the capability can change without forcing a login-service code change. That combination reduces credential handling in the worker and keeps the adapter boundary useful, while the limitations in the table still decide whether it fits.

This is where the abstraction boundary earns its keep. Normalize only the states the login service needs: pending, accepted, rejected, expired, and suppressed. Preserve the raw provider response separately for diagnosis. If a provider changes, the adapter changes; the login transaction does not.

Keep it boring.

Implement the email fallback as a single-use state machine

The fallback should begin only after an explicit user action or a terminal primary-path decision. A slow SMS status poll is not enough reason to issue a second valid secret. Otherwise two channels race, two codes remain live, and the support team cannot explain which one should work. I've been paged for missed jobs and duplicate deliveries; the durable lesson is that retries need identity, not optimism. Give the login attempt a stable ID, serialize transitions around it, and make activation of the fallback idempotent.

The following Go program is the transport adapter worth testing first. It calls the managed SMS issue and verify operations with their exact required fields. Set INFRAI_BASE_URL to the standard v1 API base, keep the bearer key in INFRAI_API_KEY, and pass a stable login-attempt ID on the command line. That ID produces a deterministic idempotency key, so restarting the caller does not create a second logical operation. The adapter sends an explicit method, bounds every request, honors numeric Retry-After values on HTTP 429, and returns the actual response body rather than guessing its schema.

package main

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

type Client struct {
    baseURL string
    apiKey  string
    http    *http.Client
}

type otpRequest struct {
    To string `json:"to"`
}

type verifyRequest struct {
    To   string `json:"to"`
    Code string `json:"code"`
}

func idempotencyKey(operation, attemptID string) string {
    sum := sha256.Sum256([]byte(operation + ":" + attemptID))
    return hex.EncodeToString(sum[:])
}

func (c *Client) post(path string, payload any, key string) ([]byte, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, c.baseURL+path, strings.NewReader(string(body)))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+c.apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", key)

        resp, err := c.http.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return responseBody, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 {
            return nil, fmt.Errorf("request returned %d: %s", resp.StatusCode, responseBody)
        }
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, errors.New("retry budget exhausted")
}

func main() {
    if len(os.Args) < 4 {
        panic("usage: otp issue PHONE ATTEMPT_ID | otp verify PHONE CODE ATTEMPT_ID")
    }
    client := &Client{
        baseURL: strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/"),
        apiKey:  os.Getenv("INFRAI_API_KEY"),
        http:    &http.Client{Timeout: 15 * time.Second},
    }
    if client.baseURL == "" || client.apiKey == "" {
        panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    var result []byte
    var err error
    switch os.Args[1] {
    case "issue":
        result, err = client.post("/sms/otp", otpRequest{To: os.Args[2]}, idempotencyKey("issue", os.Args[3]))
    case "verify":
        if len(os.Args) != 5 {
            panic("usage: otp verify PHONE CODE ATTEMPT_ID")
        }
        result, err = client.post("/sms/verify", verifyRequest{To: os.Args[2], Code: os.Args[3]}, idempotencyKey("verify", os.Args[4]))
    default:
        panic("operation must be issue or verify")
    }
    if err != nil {
        panic(err)
    }
    fmt.Println(string(result))
}
Enter fullscreen mode Exit fullscreen mode

The adapter is intentionally narrow. Store the returned body with the attempt record, but do not treat a successful issue response as proof of delivery or a successful verify response as permission to create a session until the application has checked its own attempt state. The login service must still serialize the final transition, enforce resend and verification budgets, and close the email branch after SMS verification succeeds.

Email delivery comes next. Render one plain template, enqueue it with the attempt ID as the idempotency identity, and record the send result. Poll email events because neither channel offers webhook events in this capability set. When a bounce identifies an invalid recipient, add that address to suppression and mark the branch suppressed; future login attempts should not send to it. Scheduled email is a poor fit for OTP because there is no email cancellation operation, whereas SMS does provide cancellation. Send the fallback immediately from the worker or do not schedule it at all.

Verify delivery without creating a second incident

Verification needs two dashboards: an authentication view keyed by login attempt and a delivery view keyed by provider message. They answer different questions. The first tells an operator whether a challenge was issued, accepted, rejected, expired, or suppressed. The second explains the channel outcome. Join them with internal correlation IDs, never with the code itself.

Poll with bounded exponential backoff and jitter. Honor Retry-After on HTTP 429, stop on a terminal state, and put a deadline on the whole poll cycle. No tight loops. Since there is no webhook to wake the controller, the user interface should say that the message is pending until the next poll confirms otherwise; it should not silently activate email while SMS may still arrive. Alert on the age and count of pending attempts, a rise in invalid-recipient suppression, resend volume, and country-level breaker trips. These are control signals, not vendor uptime measurements.

Run a destination matrix before rollout: representative US and EU numbers, the mailbox providers your customers actually use, GSM-7 and Unicode SMS templates, known suppressed email addresses, expired codes, repeated wrong codes, duplicate fallback requests, and a simulated 429 with Retry-After. Record expected state transitions beside each case. A delivery receipt does not prove authentication, and an accepted code must close every other branch for that login attempt.

Then test rollback.

Roll back by disabling issuance, not verification

If the primary path degrades against your own service-level threshold, stop issuing new SMS challenges for the affected destination slice and offer the explicit email fallback where policy permits it. Keep verification available for challenges already issued. Turning off both issue and verify strands users holding valid codes and converts a delivery problem into a login outage.

Rollback the adapter or routing configuration behind the stable capability contract; do not change the login state machine during an incident. Drain pollers until their deadlines, preserve raw responses for the postmortem, and reconcile attempts that were pending at the cutover. For email, keep bounce-driven suppression in force throughout rollback. Removing suppression to increase send volume is not recovery.

There are hard boundaries. This design cannot provide instant event-driven failover because both namespaces are pull-only. It cannot use the pending domestic email vendor as evidence for China compliance. It also cannot grow into voice, WhatsApp, RCS, or SMTP relay without selecting another capability or vendor. Those are reasons to choose a different architecture, not items to hide in an implementation checklist.

The final go/no-go condition is simple: every retry is idempotent, every active code has one owner and one deadline, every invalid email becomes suppressed, and operators can disable new issuance without invalidating existing challenges. If any of those statements is false, the system is not ready for login traffic.

References

Top comments (0)