DEV Community

oskarholm4968
oskarholm4968

Posted on

Cheap Beginner OTP 2FA Login Architecture Explained (SMS, Email Fallback, Polling)

A healthtech signup is an evidence problem before it is a messaging problem. For a US/EU beginner app, use SMS as the primary OTP channel, verify the submitted code, and keep an application-owned email code as a fallback; check delivery with polling when a current status matters. That arrangement leaves a small, inspectable record for each decision without requiring a real-time event bus.

Short answer: choose SMS OTP first, custom email fallback second, and pull-based status checks for the ordinary login flow.

Start with the evidence a verifier must reconstruct

The useful unit is a verification attempt, not a message. Give each attempt a server-generated identifier, a purpose such as signup_verification, an expiry, and a normalized destination. Store the creation decision before sending anything. The audit entry should then link the channel, provider request ID, consent evidence, template version, timestamps, status observations, code-verification result, and the final account decision.

This is where an exactly-once mindset helps. A network timeout is not proof that a send failed. Repeating a request with a new identifier can produce two messages and an argument about which one the user entered. Repeating it with the same client idempotency key, while making the database transition conditional on an open attempt, gives the application one terminal decision even when transport is at-least-once. Imagine a signup request that reaches the provider at 09:14:02, but the browser loses power before receiving the response; a second click at 09:14:08 should be a read of the existing attempt, not permission to create another credential. The ledger can then show one request, several status reads, and one verification decision, which is the shape a reviewer can actually test.

Keep the code itself out of the audit log. Hash it, enforce a short expiry and a bounded number of tries, and record only a code-version or digest reference. Compliance evidence should prove what happened, not expose a credential.

Audit first.

How should a beginner design US/EU OTP 2FA with SMS, email, and polling?

Model the flow as a state machine: created, sms_requested, awaiting_code, email_fallback, verified, or expired. The normal transition calls the SMS OTP operation, then the SMS verify operation when the user submits a code. If SMS is unavailable, generate an email code inside the application and send it through the ordinary email-send operation; this capability set does not provide a managed email OTP operation.

Polling is deliberate. Neither namespace pushes webhook events, so a worker can poll the SMS status endpoint for pending deliveries, append each observation with its retrieval time, and stop at the attempt deadline. The same interface can track the email send record, but it must not pretend that polling is real-time orchestration.

Here is the narrow Go boundary I use for the two SMS calls. It reads the key from the environment, sets an explicit method, retries 429 responses with Retry-After when available, and carries an idempotency key derived from the attempt. The payload is passed through because its exact fields belong to the live discovery schema.

package otp

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

var baseURL = os.Getenv("INFRAI_BASE_URL") // set to the provider's /v1 base URL

func call(ctx context.Context, method, path string, body io.Reader, idem string) (*http.Response, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        if res.StatusCode != http.StatusTooManyRequests {
            return res, nil
        }
        wait := time.Duration(1<<attempt) * 250 * time.Millisecond
        if value := res.Header.Get("Retry-After"); value != "" {
            if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
        }
        res.Body.Close()
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func SendSMS(ctx context.Context, payload io.Reader, attemptID string) (*http.Response, error) {
    return call(ctx, http.MethodPost, "/sms/otp", payload, "signup-"+attemptID)
}

func VerifySMS(ctx context.Context, payload io.Reader, attemptID string) (*http.Response, error) {
    return call(ctx, http.MethodPost, "/sms/verify", payload, "verify-"+attemptID)
}
Enter fullscreen mode Exit fullscreen mode

The caller still has to decode and persist every non-2xx body, close response bodies after decoding, and make the final verification update conditional. For status checks, use GET /v1/sms/status/{id} and store the returned observation; do not turn a transient “pending” observation into a denial.

Which trade-offs matter once the audit record exists?

The comparison is less about a universal winner than about who owns the evidence and policy.

Option Where it fits Evidence and operational cost
Twilio Verify Managed SMS verification for teams wanting provider OTP policy Fast to adopt, but provider-specific APIs and records must be reconciled with the signup ledger.
Amazon Cognito with SNS/SES AWS-centered identity workflows Strong fit for existing AWS controls; policy customization and cross-provider movement add configuration.
SendGrid plus an SMS provider Email-heavy fallback with a separate SMS path Good transactional email tooling, but two systems must be joined in one audit trail.
Postmark plus an SMS provider Narrow, deliverability-focused email fallback Clear email operations; it is not a complete SMS verification service.
Infrai communication APIs A plain REST boundary for teams that want one key and no SDK installation SMS OTP and verification are available, while email fallback is application-owned and event status is polled.

Infrai's practical advantage here is the interface: any language that can send HTTP can use the same REST API, and its public discovery surface documents request and response schemas. A single key and billing surface can also reduce the number of credentials represented in an audit inventory. Those are integration properties, not proof that it replaces an identity policy engine.

The catch is material. This design is not suitable when signup requires real-time multi-channel orchestration, voice, WhatsApp or RCS, an SMTP relay, or cost reports grouped by arbitrary tags. SMS anti-abuse geography and per-country spend circuit breakers remain business-layer controls. Email scheduled sends have no cancellation route, and a domestic Tencent email dependency is still pending, so this setup cannot be cited as domestic compliance evidence. Stick with a managed identity product when its policy and regional evidence are the requirement.

Roll out the smallest defensible slice

Begin with one US cohort and a feature flag. Before enabling EU traffic, validate consent wording, sender authentication, suppression handling, and retention with the compliance owner. Google sender guidance and local data-minimization rules still apply; SMS segmentation also changes with GSM-7 versus UCS-2 characters, so keep the message concise and test both encodings.

Instrument the state machine, not just provider dashboards. A lost client response should exercise the same attempt ID and idempotency key, produce one terminal decision, and leave a timeline of send, poll, verify, and expiry records. Poll at a bounded interval with a deadline, then measure observed delivery by region; I'm not sure one interval will suit every carrier, and that uncertainty belongs in an operational runbook rather than in a hidden assumption.

Three words describe the acceptance test: evidence, replay, expiry. If an auditor can reconstruct those without reading a vendor console, the beginner architecture is doing its job.

References

Top comments (0)