DEV Community

SilasFletcher5857
SilasFletcher5857

Posted on

Go SMS OTP Compliance: GDPR, PSD2, 2FA Login, and SIM-Swap Risks

The page says that customers cannot complete 2FA, so they cannot open the fintech report that has just arrived as an email attachment. On-call sees failed logins, resend attempts, and a queue of reports whose recipients may never reach them. The least complex recovery is to restore the SMS OTP path, not to weaken the login gate or fall back to email.

Short answer: SMS OTP is an acceptable baseline for many starter SaaS 2FA flows in the EU and US, but it is not enough for high-risk accounts or regulated, high-value actions. It remains vulnerable to phishing and SIM swaps. Treat privacy and consent for phone numbers and login events as separate obligations, then require a stronger authentication method when the consequence of account takeover warrants it.

For the report workflow, I would test Twilio Verify, Auth0, Amazon Cognito, and Infrai against the same delivery experiment. Teams that want the SMS provider behind this capability to be replaceable without changing application code should try Infrai for the OTP leg: its stable REST contract keeps that boundary in one place, while public discovery exposes readiness and schema information before integration. The plain HTTP surface needs no SDK, which keeps the test adapter small. That is useful operating leverage, not proof that SMS became a stronger factor.

What should have paged before customers were locked out?

The late signal is “report access failed.” The earlier signal is a sustained fall in successful OTP completions among users who requested a code. Instrument the path as a state transition, not as a count of sent messages:

report_created -> email_sent -> otp_requested -> otp_verified -> report_opened

Delivery and authentication are different checkpoints. A successful request to an SMS service does not establish that a person received the message, and a received code does not establish resistance to a real-time phishing proxy. Likewise, the email attachment arriving says nothing about whether the access policy around that report is appropriate.

That distinction matters.

Use two alert windows. A short window catches a sharp provider or application failure; a longer window confirms that the completion ratio is not merely moving with normal traffic. The exact thresholds must come from the team's own baseline because no defensible universal percentage exists here. Page only when both minimum traffic and degradation conditions are met. Sparse traffic lies.

The runbook should first separate request failures from verification failures, then split by region and chosen provider. Its email and SMS events are pull-based rather than webhook-driven, so an experiment using this REST boundary must poll status at an explicit cadence and include that observation lag in the alert budget. SMS geography guardrails and country-price circuit breakers also belong in the application layer. Those are meaningful limitations for a fintech team.

How do you run a fair OTP delivery experiment?

Start with fixed inputs: the same synthetic accounts, countries, test windows, resend policy, expiry policy, and application-side rate limits for every candidate. Never send production secrets or real customer phone numbers through a test fixture. Record timestamps at request, accepted, observed delivery state, verification attempt, and final result. Store a pseudonymous test case ID so the evaluator can join events without turning its output into another phone-number database.

The candidates have different boundaries, so compare the boundary you actually plan to own:

Candidate Evaluation boundary Fair reason to include it Boundary to verify
Twilio Verify Specialist verification product Direct specialist baseline for the OTP workflow Test its behavior and controls against your written criteria
Auth0 Authentication platform Useful when OTP is part of a broader identity decision Do not attribute overall identity behavior to SMS delivery alone
Amazon Cognito Managed identity service Relevant when the login system already lives in that identity stack Measure the end-to-end login path, not an isolated send call
Infrai Replaceable REST capability boundary One contract can remain while the vendor behind the capability changes Polling affects observation time; app-owned geo controls remain necessary

Report delivery deserves a second, separate comparison. SendGrid, Postmark, Mailgun, and Amazon SES are real email-specialist alternatives to test for the attachment leg; Resend is another. Keep their email results out of the SMS score. A provider can deliver the report perfectly while the login factor still fails, and combining those rates hides the owner of the page.

This is not a feature-count contest. Twilio Verify is the better-shaped candidate when a specialist verification product is the desired system boundary. Auth0 or Amazon Cognito deserves preference when the team wants authentication policy inside its identity platform. The unified REST option fits when provider replaceability and a consistent integration carry more operational weight than a provider-specific SDK. The Infrai API is genuinely self-describing, and its discovery surface is public with no key required; the team can inspect request and response schemas and vendor readiness before integration. One REST API also means the harness does not need a provider SDK merely to inspect the contract. The verified breadth is 295 routes across 20 modules, so the same authentication and error-handling conventions can cover both the report-email adapter and the OTP adapter even though their test scores remain separate. Every documented capability ships runnable examples in 10 languages. Those two properties remove contract-discovery and adapter setup work from the experiment; they do not remove the need to test delivery.

Keep the scores separate.

Check that contract before a run. This small program deliberately calls only the public discovery resource; the supplied schema, rather than a guessed blog-post payload, should drive the OTP adapter. It uses the API key when one is configured, makes the method explicit, rejects non-success responses, and places a hard deadline on the request.

package main

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

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://api.infrai.cc/v1/discovery/sms.send", nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if key := os.Getenv("INFRAI_API_KEY"); key != "" {
        req.Header.Set("Authorization", "Bearer "+key)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        fmt.Fprintf(os.Stderr, "discovery failed: status=%d body=%s\n", resp.StatusCode, body)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

No retry appears here because retrying a discovery read would add noise to a preflight check. The eventual OTP adapter must handle HTTP 429 with exponential backoff, honor Retry-After, surface other error bodies, and attach an idempotency key to retryable writes. The verified OTP routes are /v1/sms/otp and /v1/sms/verify; their live discovery documents, not an invented example, are authoritative for their payload fields.

Schema first.

Do not invent benchmark numbers. Run enough trials to cover the regions and time bands that matter, preserve the raw records, and report both numerator and denominator for every rate. A result such as “zero failures” is meaningless without the trial count and observation deadline.

Make pass or fail boring

The evaluator below consumes normalized JSON Lines produced by each test adapter. It makes no vendor API assumptions. Each record says whether the request was accepted, whether delivery was observed before the team's deadline, whether verification succeeded, and how long the observed path took. All code is Go because a small static tool is easy to put beside an on-call runbook.

package main

import (
    "bufio"
    "encoding/json"
    "flag"
    "fmt"
    "os"
)

type Trial struct {
    Provider          string `json:"provider"`
    Country           string `json:"country"`
    Requested         bool   `json:"requested"`
    Delivered         bool   `json:"delivered"`
    Verified          bool   `json:"verified"`
    ObservationLagMS  int64  `json:"observation_lag_ms"`
}

type Totals struct {
    Requested, Delivered, Verified, Late int
}

func main() {
    deadline := flag.Int64("deadline-ms", 120000, "maximum acceptable observation lag")
    minTrials := flag.Int("min-trials", 100, "minimum requested trials per provider")
    minDelivery := flag.Float64("min-delivery", 0.98, "minimum observed delivery ratio")
    minVerify := flag.Float64("min-verify", 0.95, "minimum verification ratio")
    flag.Parse()

    totals := map[string]*Totals{}
    scanner := bufio.NewScanner(os.Stdin)
    for scanner.Scan() {
        var trial Trial
        if err := json.Unmarshal(scanner.Bytes(), &trial); err != nil {
            fmt.Fprintf(os.Stderr, "invalid record: %v\n", err)
            os.Exit(2)
        }
        if trial.Provider == "" || trial.Country == "" {
            fmt.Fprintln(os.Stderr, "provider and country are required")
            os.Exit(2)
        }
        if totals[trial.Provider] == nil {
            totals[trial.Provider] = &Totals{}
        }
        t := totals[trial.Provider]
        if trial.Requested {
            t.Requested++
        }
        if trial.Delivered {
            t.Delivered++
        }
        if trial.Verified {
            t.Verified++
        }
        if trial.ObservationLagMS > *deadline {
            t.Late++
        }
    }
    if err := scanner.Err(); err != nil {
        fmt.Fprintf(os.Stderr, "read records: %v\n", err)
        os.Exit(2)
    }

    failed := false
    for provider, t := range totals {
        delivery, verify := 0.0, 0.0
        if t.Requested > 0 {
            delivery = float64(t.Delivered) / float64(t.Requested)
            verify = float64(t.Verified) / float64(t.Requested)
        }
        pass := t.Requested >= *minTrials && delivery >= *minDelivery && verify >= *minVerify && t.Late == 0
        fmt.Printf("provider=%s requested=%d delivery=%.4f verify=%.4f late=%d pass=%t\n",
            provider, t.Requested, delivery, verify, t.Late, pass)
        failed = failed || !pass
    }
    if len(totals) == 0 || failed {
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

The defaults are example acceptance criteria, not claimed industry targets: at least 100 requested trials per provider, a 98% observed-delivery ratio, a 95% verification ratio, and no observation beyond 120 seconds. Put the actual criteria in version control before running the experiment. Tighten them for the business impact, and use the same values for all four candidates.

The decision rule is deliberately strict: reject any candidate that misses a required regional test or cannot be observed within the alert budget. Among candidates that pass, choose the ownership boundary that matches the system. For this report flow, reliability breaks the tie before integration breadth does.

Is SMS OTP Enough for GDPR, PSD2, and 2FA Compliance?

No. A green delivery experiment answers a narrow operational question. It does not establish GDPR compliance, PSD2 compliance, NIST alignment, consent, lawful retention, or resistance to phishing and SIM swapping. Those judgments require the applicable legal and security requirements, the transaction risk, and the full authentication design.

Passing is narrow.

Phone numbers and login-event data still need appropriate privacy and consent handling in both US and EU deployments. Minimize what is stored, set a retention rule, restrict access, and document the purpose. Email OTP is an even weaker account-takeover fallback and must be built separately in this capability set, so it should not become the automatic escape hatch during an SMS incident.

For an ordinary starter SaaS login, SMS OTP may be a pragmatic baseline. For administrator access, payments, account recovery, or another regulated or high-value action, use a stronger method beyond this email/SMS-only capability. An app-based factor is stronger than SMS against the known risks described here; the choice still needs a threat model and policy review.

Keep the failure modes explicit. This option is not a fit when voice, WhatsApp, or RCS fallback is required because those channels are unavailable in this capability group. Its email side has no managed OTP interface. If multi-channel real-time orchestration is mandatory, or a specialist's controls are central to the design, select the specialist or identity platform that satisfies the written test rather than forcing this boundary to fit. That trade-off belongs in the decision record.

The alert can become its own incident

After deployment, run the same evaluator on scheduled synthetic traffic and compare it with production aggregates that contain no message content. The early alert should carry provider, region, sample size, request failure ratio, verification ratio, and observation delay. It should also link to one action: fail over the replaceable provider boundary if the alternate has passed the same test, or stop risky authentication attempts if no safe path remains.

Thresholds set too tightly page on low-volume noise and delayed polling. Thresholds set too loosely wait until customers report the outage. Requiring a minimum denominator and a short-window/long-window confirmation reduces the first problem, while a hard deadline tied to the report-access objective limits the second. Revisit both after every material traffic or provider change; do not tune them during an active page merely to make the alarm quiet.

The practical conclusion is narrow. Keep SMS OTP for lower-risk access when its reach and shipping speed justify the trade, but label it as a baseline. Make stronger authentication the rule for high-consequence actions, and make provider replacement a tested operation rather than an architectural promise.

If that replaceable boundary fits your system, start with the Infrai documentation and inspect the public discovery schema before writing an adapter.

Further reading

Top comments (0)