DEV Community

Hwpgsd503817
Hwpgsd503817

Posted on

SMS OTP for Pharmacy Refill Alerts: GDPR, PSD2, and NIST 2FA Login Risks

Short answer: SMS OTP is a reasonable baseline for a pharmacy refill-alert signup, but it is not enough for every EU or US compliance-sensitive action. Treat it as a possession check with known phishing and SIM-swap exposure, then step up to app-based MFA or a stronger factor before changing payment details, exporting prescriptions, or approving a high-value refill.

The alert page usually fires first. A support engineer sees a spike in “I never got the code” tickets, while the delivery dashboard still reports accepted messages. That is the wrong end of the trace. The signal that should have fired earlier is a rising gap between OTP requests and verified sessions by country, carrier, and phone-number age.

For a pharmacy system, that gap matters more than a green provider status page. A delayed code can block a refill, but an intercepted code can hand an account to somebody else.

Measure it.

Is SMS OTP enough for GDPR and PSD2 compliance?

Use SMS OTP for the low-friction part of the journey: confirming a phone number at signup, recovering a low-risk session, or sending a refill reminder that does not disclose medication details. Keep the message content sparse. “Your refill is ready; sign in to view it” gives a person a useful prompt without putting a prescription in a lock-screen notification.

The boundary is the important part. SMS is common and easy to ship, yet it is weaker than an authenticator app or a hardware-backed passkey. SIM swaps, number recycling, phishing pages, and malware can all defeat the assumption that possession of a number proves possession of the patient. Email fallback is weaker still for account-takeover resistance, so it should be a separately designed recovery path rather than an automatic escape hatch.

GDPR and US privacy rules still apply to the phone number, consent record, and login-event data you retain. PSD2-style strong customer authentication can require more than an SMS-only factor for a regulated payment action, and NIST guidance treats SMS as a restricted, weaker channel. Your compliance owner has to map the exact action and jurisdiction; an OTP API cannot make that decision for you.

How do you turn an OTP alert into an observable SLO?

Start with an SLO that a patient can feel: for example, 99% of requested codes accepted within two minutes, measured separately from provider acceptance. Then instrument the whole trace: request, send response, delivery status, verify attempt, and final session issuance. Store a request ID and a coarse event timestamp, not the code itself. Retain only the phone-number data and event detail your privacy review approves. The useful dashboard is not a single delivery percentage; it is a set of slices that lets the on-call distinguish a carrier delay from an attack. Compare first-attempt verification with resends, break it down by country and carrier, and watch recently ported or recycled numbers separately if your risk team can provide that signal. In a refill-alert flow, a burst at 8 a.m. may be normal, while the same burst paired with repeated verification failures and a new device fingerprint deserves a step-up challenge. Keep the alert threshold tied to the cost of a page: a false positive wakes somebody up and trains them to ignore the next one, but a slow page can leave an account takeover running through an entire shift. The SLO is a contract with the patient and the on-call rotation, not a marketing number.

When the alert fires, work backward. Did requests jump for one country? Did resend volume climb after a carrier change? Did verification fall only for recently ported numbers? A threshold that is too low pages the on-call for normal evening refill traffic; a threshold that is too high lets a fraud burst run until the support queue notices. I would rather tune this with a week of percentile data than pretend one global threshold fits every carrier.

This is the small Go client I use to keep the request and verification steps explicit. It uses only the documented SMS OTP routes, reads the bearer key and base URL from the environment, and treats a 429 as a signal to back off rather than hammering the service.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type otpRequest struct {
    Phone string `json:"phone"`
}

func call(method, path string, payload any) ([]byte, int, error) {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return nil, 0, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, 0, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, 0, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, 0, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, resp.StatusCode, readErr
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                return data, resp.StatusCode, fmt.Errorf("sms request failed: %s", string(data))
            }
            return data, resp.StatusCode, nil
        }
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, http.StatusTooManyRequests, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    phone := os.Getenv("PATIENT_PHONE")
    if phone == "" || os.Getenv("INFRAI_API_KEY") == "" {
        panic("PATIENT_PHONE and INFRAI_API_KEY are required")
    }
    if _, _, err := call(http.MethodPost, "/sms/otp", otpRequest{Phone: phone}); err != nil {
        panic(err)
    }
    fmt.Println("OTP requested; collect the code in the UI and verify it before creating a session")
}
Enter fullscreen mode Exit fullscreen mode

The verify call belongs behind your own session and abuse controls, using POST /v1/sms/verify with the response fields defined by discovery. Do not log the code, and make a retry safe by attaching your own signup transaction ID to the surrounding workflow. SMS delivery has no webhook event stream here, so a poll-based status view and your own timeout metric are part of the design.

Which delivery options fit the risk and operating model?

There is no universal winner. The table is deliberately about operating shape, not a price contest.

Option Where it fits Trade-off to carry
Twilio Verify A team that wants a mature, verification-focused managed workflow More vendor-specific policy and integration surface to own
Vonage Verify A team already using Vonage messaging relationships Switching costs rise if other channels live elsewhere
Amazon SNS A team standardized on AWS primitives and IAM The application owns more OTP state, policy, and abuse controls
A self-hosted SMS gateway A regulated environment with a strong telecom operations team Carrier reach, deliverability, and 24/7 on-call become your problem
Infrai SMS routes A small platform team that wants plain HTTP and one credential across backend capabilities No webhook events, no hosted email OTP, and country-level anti-fraud controls must be built in the application

Infrai's useful distinction is the plain REST surface: any language that can send HTTPS can call it, so there is no SDK version to babysit. That can reduce integration friction for a Go service, while its single-key platform model is convenient when the same account also needs unrelated backend capabilities. It does not remove the security work above.

Where is SMS OTP the wrong choice?

The catch is that SMS OTP is not suitable when a stolen number would authorize a high-impact action. For prescription export, payout or payment-method changes, clinician-admin access, and recovery after a suspicious login, require a phishing-resistant authenticator or passkey and use step-up verification. Stick with a stronger identity provider when you need policy engines, risk scoring, or a regulated audit package that this email/SMS capability does not provide.

There are practical limits too: no SMTP relay, no voice, WhatsApp, or RCS channel, no real-time webhook events, and no geography-based SMS spend fuse. Email appointment sending has no cancel operation, and the domestic Tencent email vendor is still pending, so it is not evidence for domestic compliance. Build those controls at the business layer or choose a provider whose managed workflow includes them.

I am not sure one SLO target will survive every carrier mix; your mileage may vary. Recheck the threshold after a real refill cycle, and have privacy and compliance reviewers sign off on retention before launch.

References

Top comments (0)