DEV Community

IronspireDraven77
IronspireDraven77

Posted on

Implementing Go SMS OTP: GDPR PSD2 NIST Compliance for Logistics Login

Short answer: SMS OTP can prove that a user can receive a text at one moment, but it should not be treated as sufficient protection for a logistics account that exposes settled-payment receipts. GDPR, PSD2, and NIST ask different questions, so no single channel earns a universal compliance label. Use a risk gate: permit a text code for bounded, low-impact recovery or verification, then require a phishing-resistant authenticator before showing receipts, changing payout details, or performing any action whose compromise would create a reportable incident.

A receipt sounds harmless until it contains a customer name, delivery address, order contents, tax identifiers, or a link into a broader account. The operational question is not "did the code arrive?" It is "what page fired after the code was accepted, and what could an attacker do there?" Delivery dashboards cannot answer that. Authorization logs can.

Is SMS OTP enough for GDPR, PSD2, and NIST login compliance?

No.

Consider a bounded incident exercise, not a claimed production story. Payment for order ord_8F21 settles at 02:13 UTC. The receipt worker sends a notification, and the account later accepts a valid SMS code. At 02:19, a new device downloads the receipt and changes the delivery contact. Every component can display green while the customer still loses confidentiality: the carrier delivered the message, the OTP service matched the code, and the HTTP handler returned 200.

The missing control is intent-bound, risk-aware authorization. An OTP is evidence about access to a telephone number; it does not establish how that access was obtained. A number can move between SIMs, a code can be entered into a phishing page, and a message can appear on a locked screen. Those are distinct failure modes, yet they all survive a dashboard that counts successful sends.

This distinction matters across the named regimes. GDPR is a data-protection framework, so the defensible question is whether security measures fit the risk to personal data. PSD2 strong customer authentication applies to covered payment services and frames authentication around independent elements, with important scope and exemption details; it is not a blanket certification for every receipt page. NIST's digital identity guidance treats use of the public switched telephone network as a restricted authenticator and requires verifiers to consider risks such as SIM change and number porting. None of those statements turns "SMS passed" into "the system complies." The limitation is structural: adding retry logic, another carrier, or a longer code can improve delivery or guessing resistance, but it cannot make a phished code resistant to phishing. That trade-off is acceptable only where the consequence is deliberately bounded.

That is the invariant: authentication strength must follow the consequence of the next action, not the convenience of the channel.

Model the page that fires, not the message that arrives

Start with assets and actions. For a settled logistics order, separate a generic "your payment settled" notification from the authenticated receipt, the delivery address, account recovery, and edits to recipient or payout data. Keep sensitive fields out of the SMS body. A bearer link in a text merely moves the secret from six digits into a URL; forwarded messages, previews, and logs can still expose it.

Use short-lived challenges, one-time consumption, server-side attempt limits, and a transaction identifier that binds the challenge to the intended account and action. Avoid storing raw OTP values. More important, do not let successful verification silently upgrade an entire session beyond the action the user proved. Five failed guesses from one IP are easy to see. One guess each from 5,000 addresses is why per-IP throttling alone is weak.

A practical decision table keeps policy out of handler folklore:

Action after authentication Likely impact SMS-only decision Additional control
Confirm possession during low-risk enrollment Limited if no account data is exposed May be acceptable after risk review Rate limits and anti-enumeration
View a redacted receipt Personal-data disclosure Conditional, not automatic Device and session risk checks
Download a full receipt or address Material confidentiality impact Reject as the sole factor Phishing-resistant step-up
Change recovery, recipient, or payout details Account takeover or financial impact Reject as the sole factor Strong step-up plus change notification

The table is policy, not law. Your data inventory, payment role, threat model, and competent legal review determine the actual boundary. Keep the decision versioned so an incident reviewer can reconstruct why an action was allowed.

Put the preventative decision in Go

The following standard-library example makes the authorization boundary explicit. It does not send messages or implement a passkey ceremony; those belong behind reviewed interfaces. The useful part is that a valid SMS challenge cannot authorize a high-impact receipt action by accident.

package authz

import (
    "errors"
    "time"
)

type Assurance int

const (
    AssuranceNone Assurance = iota
    AssuranceSMS
    AssurancePhishingResistant
)

type Action string

const (
    ConfirmEnrollment Action = "confirm_enrollment"
    ViewRedactedReceipt Action = "view_redacted_receipt"
    DownloadFullReceipt Action = "download_full_receipt"
    ChangeDeliveryContact Action = "change_delivery_contact"
)

type Evidence struct {
    AccountID string
    DeviceID string
    Assurance Assurance
    VerifiedAt time.Time
    BoundAction Action
    RiskScore int
}

func Authorize(now time.Time, want Action, e Evidence) error {
    if e.AccountID == "" || e.DeviceID == "" {
        return errors.New("missing subject or device binding")
    }
    if now.Sub(e.VerifiedAt) > 5*time.Minute || now.Before(e.VerifiedAt) {
        return errors.New("authentication evidence expired")
    }
    if e.BoundAction != want {
        return errors.New("authentication was issued for another action")
    }

    switch want {
    case ConfirmEnrollment:
        if e.Assurance < AssuranceSMS || e.RiskScore >= 30 {
            return errors.New("enrollment requires additional verification")
        }
    case ViewRedactedReceipt:
        if e.Assurance < AssuranceSMS || e.RiskScore >= 20 {
            return errors.New("receipt view requires step-up")
        }
    case DownloadFullReceipt, ChangeDeliveryContact:
        if e.Assurance < AssurancePhishingResistant {
            return errors.New("phishing-resistant step-up required")
        }
    default:
        return errors.New("unknown action")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The numeric thresholds are example policy values, not benchmark results or regulatory safe harbors. Put them in a reviewed policy package, test boundary values, and record the policy version with the authorization event. Do not let a client submit its own assurance level or risk score.

Test the failure path first. A system that proves only the happy path tends to discover replay and action-confusion bugs during an incident.

package authz

import (
    "testing"
    "time"
)

func TestSMSCannotAuthorizeFullReceipt(t *testing.T) {
    now := time.Date(2026, 4, 10, 2, 13, 0, 0, time.UTC)
    evidence := Evidence{
        AccountID: "acct_41",
        DeviceID: "device_new",
        Assurance: AssuranceSMS,
        VerifiedAt: now.Add(-30 * time.Second),
        BoundAction: DownloadFullReceipt,
        RiskScore: 5,
    }

    if err := Authorize(now, DownloadFullReceipt, evidence); err == nil {
        t.Fatal("SMS evidence authorized a full receipt download")
    }
}

func TestEvidenceCannotCrossActions(t *testing.T) {
    now := time.Date(2026, 4, 10, 2, 13, 0, 0, time.UTC)
    evidence := Evidence{
        AccountID: "acct_41",
        DeviceID: "device_known",
        Assurance: AssurancePhishingResistant,
        VerifiedAt: now.Add(-30 * time.Second),
        BoundAction: ViewRedactedReceipt,
    }

    if err := Authorize(now, ChangeDeliveryContact, evidence); err == nil {
        t.Fatal("evidence escaped its bound action")
    }
}
Enter fullscreen mode Exit fullscreen mode

Keep the SMS delivery path idempotent as well. Payment settlement events are commonly retried by distributed systems; the receipt worker should deduplicate on a stable event identifier so a retry does not create a fresh authentication challenge or a storm of texts. Delivery reliability and authentication assurance are separate controls. Treat them separately.

Observe decisions without leaking secrets

Log the authorization decision, account pseudonym, action, assurance class, device continuity, risk-policy version, outcome, and a reason code. Never log the OTP, full phone number, receipt URL, session token, or receipt contents. Retention should follow a documented purpose and schedule rather than "keep everything in case security asks."

The page should fire an alert when high-impact actions are allowed with insufficient assurance, when challenge attempts spread across many network sources, or when a recovery event is followed quickly by receipt access or profile changes. A raw delivery-rate alert is secondary. It tells the communications team that messages are failing; it does not tell the incident responder that an account boundary failed.

Use coarse, stable reason codes such as expired_evidence, action_mismatch, and step_up_required. Free-form error text changes, can absorb personal data, and makes trend analysis brittle. For customer-facing responses, avoid revealing whether a phone number or account exists. Internally, retain enough correlation to join the settlement event, notification attempt, authentication decision, and receipt access without copying payloads into every system.

This is also where privacy and operations pull in opposite directions. More telemetry may shorten an investigation, while excessive identifiers enlarge the data exposed by the logging system. The right compromise is purposeful fields, pseudonymous joins, access controls, and tested retention deletion, not maximum collection.

When can SMS still be proportionate?

SMS can remain useful as a notification channel and, after a documented risk assessment, as one part of low-impact verification. It may also be the only reachable option for some users. Accessibility and recovery deserve design work, but broad availability does not erase phishing or number-transfer risk. SMS-only authentication is not suitable for an action whose expected control is resistance to verifier impersonation; a phishing-resistant authenticator has a stronger security property, at the cost of more involved enrollment and recovery.

For higher-impact login and account changes, prefer an authenticator that resists verifier impersonation, then design recovery so it does not collapse back to SMS as the universal bypass. A perfect primary factor paired with a weak recovery path is a weak account. Test enrollment, replacement, lost-device handling, and administrator override as aggressively as login.

This advice does not imply that every receipt requires the strongest possible ceremony. A public, non-personal receipt lookup with separately redacted data has a different consequence from a customer portal containing addresses and payment history. Nor does PSD2 automatically govern a merchant's post-payment receipt view. Map the organization's role and the exact action before assigning controls.

The deployable rule is concise: SMS OTP is a restricted signal, not a compliance outcome. Bind authentication to one action, step up for sensitive receipt access and account changes, instrument the authorization decision, and make recovery at least as resistant to takeover as the front door. Then, during an incident, the team can answer what page fired and why.

Sources

Top comments (0)