DEV Community

IngramCole6479
IngramCole6479

Posted on

US/EU SaaS Login OTP Fallbacks — SMS vs Email Security, Deliverability, and Rate Limits

For a US/EU SaaS login, use SMS OTP as the primary second factor when integration effort is the deciding constraint, and keep email OTP as an application-owned fallback. That recommendation is less about a claim that SMS is universally safer than email and more about what the team must build, observe, and reconcile before a code can be trusted.

The invariant is simple: one login attempt gets one challenge, one accepted code, and one audit record. Delivery is not proof of possession until the verification step succeeds. Treating those as separate events prevents a late email, a retried SMS request, or a duplicate callback from turning into two successful logins.

What is the best SMS or email OTP practice for a SaaS login?

Start with the threat model, then select the channel. SMS is exposed to SIM-swap and number-recycling risk; email is exposed to mailbox takeover and forwarding rules. Neither channel is a substitute for phishing-resistant authentication, but both can be a practical second factor for a SaaS product whose users already have a verified phone number or mailbox.

The first boundary is the challenge record.

Make it boring.

In a payment-adjacent login, I write the state transition down before choosing a vendor. A request creates challenge_id with an expiry and an attempt budget; a transport response records what the provider accepted; a verification response records what the user proved; and a single database transaction marks the challenge consumed before a session is issued. Imagine the worker is killed after the provider accepts the SMS but before the database commit. A retry must carry the same idempotency key, and the audit row must distinguish “accepted for delivery” from “verified by the user.” Now imagine the opposite ordering: the database says the code is consumed, but the HTTP client times out while reporting success. The session endpoint should return the existing decision when given the same challenge, not create a second one. This is why I prefer a deterministic challenge identifier over a timestamp assembled at the edge. It gives reconciliation a stable join key, lets a rate limiter count attempts consistently, and makes an incident review answerable without trusting message opens or carrier callbacks. The exact retention period is a policy choice; the invariant is that expiry and consumption are checked server-side, every time.

Deliverability is an operational property, not a checkbox. SMS can arrive quickly, yet country routing, carrier filtering, and sender policy vary across the US and EU. Email may be delayed, bulk-foldered, or measured inaccurately by privacy features such as Apple's Mail Privacy Protection. A delivered message is therefore only an attempted transport event; the authorization decision belongs to the verifier.

Rate limiting needs two dimensions. Limit the challenge endpoint by account, device, IP range, and destination, and limit verification attempts by challenge identifier. A five-attempt budget that is shared across all channels is easier to reason about than independent counters that let an attacker alternate between SMS and email. Add a cooldown for resend, and record every allow, deny, expiry, and lockout with a request identifier.

The US/EU detail matters. Country-specific pricing cutoffs, geographic fences, and anti-fraud throttles are application-layer policy, so the messaging provider should not be treated as the policy engine. If the business cannot explain why a request to a country is allowed, it should not send the code.

A governance-first decision record

For the receipt-after-payment workflow that motivates this service, authentication is on the critical path to the account that displays a settled order. The payment ledger remains authoritative; an OTP service may authorize a session, but it must never mutate the ledger. I keep these boundaries explicit because “exactly once” is a useful design target even when a network can only provide at-least-once delivery.

Option Integration effort Security and deliverability trade-off Best fit
Twilio Verify Low for managed SMS verification; separate email design needed Mature SMS workflow, but phone-channel risks and regional policy remain yours Teams already standardized on Twilio operations
Vonage Verify Low for SMS verification; channel coverage depends on the account Useful global reach, with the same SIM-swap and anti-abuse obligations Products with an existing Vonage relationship
MessageBird Verify Low-to-medium, depending on existing messaging setup Verification primitives are available, while sender and country rules still need review Teams consolidating communications with MessageBird
A direct email OTP implementation High: generate, store, expire, send, and verify Mailbox security and deliverability become your responsibility Products whose users cannot receive SMS
A unified backend surface such as Infrai Low for the SMS path because OTP and verify are dedicated operations One contract can cover more backend capabilities; application-layer geo and abuse controls remain required Junior teams optimizing for fewer integrations

The final row is not a blanket winner. Its useful distinction is breadth behind a simple surface: adding another backend capability can follow the same REST contract and key rather than introducing another SDK and credential lifecycle. That reduces integration work in this particular login path. It does not remove the need to design the email fallback, and it does not turn pull-based event lists into webhooks.

A minimal critical path in Go

The application owns the challenge state. The provider owns transport and verification for the SMS branch. The following skeleton keeps the durable decisions in one transaction boundary and uses the two documented operations without making delivery itself an authorization signal. I've found that this split also makes a payment receipt easier to reconcile: a session decision can be replayed in an audit query without replaying a message.

package otp

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

type Store interface {
    CreateChallenge(context.Context, string, string, time.Time, string) error
    ConsumeChallenge(context.Context, string, string) (bool, error)
    WriteAudit(context.Context, string, string, string) error
}

type Sender interface {
    SendSMS(context.Context, string, string, string) error
    VerifySMS(context.Context, string, string) (bool, error)
}

func postInfrai(ctx context.Context, path string, body []byte, idem string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return errors.New("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("INFRAI_BASE_URL") // set this to the documented API base, ending in /v1
    if baseURL == "" { return errors.New("INFRAI_BASE_URL is required") }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+path, strings.NewReader(string(body)))
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return err }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if v, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { wait = time.Duration(v) * time.Second }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("infrai status %d: %s", resp.StatusCode, string(data))
        }
        return nil
    }
    return errors.New("rate limit retry budget exhausted")
}

func NewChallengeID() (string, error) {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

func Start(ctx context.Context, store Store, sender Sender, account, phone string) error {
    id, err := NewChallengeID()
    if err != nil {
        return err
    }
    expires := time.Now().Add(5 * time.Minute)
    if err := store.CreateChallenge(ctx, id, account, expires, "sms"); err != nil {
        return err
    }
    // The sender calls POST /v1/sms/otp with the challenge id as its idempotency key.
    if err := sender.SendSMS(ctx, phone, id, id); err != nil {
        _ = store.WriteAudit(ctx, id, "send_failed", err.Error())
        return err
    }
    return store.WriteAudit(ctx, id, "challenge_created", "sms")
}

func Verify(ctx context.Context, store Store, sender Sender, id, code string) error {
    ok, err := sender.VerifySMS(ctx, id, code) // POST /v1/sms/verify
    if err != nil {
        _ = store.WriteAudit(ctx, id, "verify_error", err.Error())
        return err
    }
    if !ok {
        _ = store.WriteAudit(ctx, id, "verify_denied", "invalid_or_expired")
        return errors.New("verification denied")
    }
    consumed, err := store.ConsumeChallenge(ctx, id, code)
    if err != nil || !consumed {
        return errors.New("challenge already consumed or unavailable")
    }
    return store.WriteAudit(ctx, id, "verify_accepted", "session_may_be_issued")
}
Enter fullscreen mode Exit fullscreen mode

The production Sender should send Authorization: Bearer <key>, set an explicit POST method, inspect non-2xx responses, and retry a 429 with exponential backoff while honoring Retry-After. The id is the client-supplied idempotency key for the create operation; the store's consume step is the second fence that prevents a replay from issuing another session. A real adapter should also persist the provider request ID and latency in the audit record, because reconciliation is easier when transport and authorization can be correlated.

For email fallback, replace the sender with your own mail pipeline: generate a cryptographically random code, store only a digest with an expiry and attempt counter, send through a configured provider, and atomically consume it after verification. There is no managed email OTP operation in this capability set. Email event data is pull-based, so a real-time “SMS failed, send email now” decision requires a poller or an application timeout; there is no webhook push to make that transition instantaneous.

Fallback policy is a security control, not a delivery toggle

Do not silently switch channels after an untrusted client reports that a message did not arrive. Require a fresh risk check, preserve the original challenge in the audit trail, and cap fallback attempts. If the mailbox is newly changed, the phone number is recycled, or the country policy is unknown, step up to a stronger factor or ask the user to recover through a separately protected process.

The catch is operational ownership. A team without an email-sending, suppression, and domain-authentication practice should not choose email as its default merely because it appears cheaper or familiar. DMARC alignment, bounce handling, and mailbox reputation are real work; privacy-oriented clients also make open tracking a weak signal. Stick with a dedicated verification product when its operational runbooks and regional coverage are more valuable than reducing the number of integrations.

Five attempts. Then stop.

I am not sure any channel-only comparison can predict your abandonment rate: country mix, carrier, mailbox provider, and user behavior dominate it. Instrument challenge creation, send acceptance, verification success, expiry, and fallback conversion separately, then review those measures by country rather than averaging US and EU traffic into one reassuring number.

The option I would reject for this login

I would reject an email-first design for this specific login unless phone collection is impossible or the product's risk policy explicitly accepts mailbox takeover as the dominant threat. It creates a larger critical path for a junior team: code generation, secure storage, expiry, resend semantics, outbound authentication, suppression, and abuse controls all belong to the application. It is a valid choice for a low-risk product with a reliable verified mailbox, but it is not the shortest path to a defensible US/EU login.

The recommendation is intentionally modest: primary SMS OTP, custom email fallback, shared rate limits, and an audit record that can explain every decision. Review the policy when phishing-resistant passkeys become a product requirement; OTP should then remain a recovery or compatibility path rather than the strongest factor.

References

Top comments (0)