DEV Community

oskarholm4968
oskarholm4968

Posted on

Suppression Lists Explained: SMS OTP 2FA Eligibility for Logistics Dispatchers

An authentication service should decide whether a phone number is eligible before it creates or sends an SMS challenge, yet it must keep the reason for refusal separate from the public login response. The central trade-off is between delivery feedback that arrives late and an interactive login path that must answer now: treat suppression as durable recipient state, update it from authenticated delivery events, and make challenge creation idempotent. Short answer: normalize the number, read its current suppression state, atomically create one challenge and one outbox record, then process bounce or failure events into an auditable state machine. Return the same generic response for blocked, unknown, and accepted recipients so the suppression list does not become an account-discovery endpoint.

In a logistics operation, this matters at awkward moments: a dispatcher may request a code while assigning a delayed load, but the carrier contact number may have been disconnected since the previous shift. Retrying the same dead destination does not improve authentication. It creates ambiguous challenges, noisy delivery data, and pressure to bypass controls.

How should an SMS OTP 2FA suppression list block dead numbers?

A provider rejection is an observation, not the state model. Some failures are permanent, some are transient, and some say nothing about the recipient at all. The application therefore needs its own small vocabulary: active, temporarily_suppressed, and permanently_suppressed, accompanied by a reason, source event identifier, effective time, and optional expiry. This is less convenient than a Boolean blocked column, but it preserves the distinction required for reconciliation and review.

The login service should consult that record before challenge issuance. A permanent invalid-recipient signal can suppress future sends until the number changes or an authorized review clears it; a transient delivery problem can expire under a documented policy. Exact classifications depend on the delivery channel's authenticated event contract, so keep the mapping at the adapter boundary rather than scattering provider-specific strings through authentication code.

Do not reveal the branch. OWASP recommends consistent messages and response timing for account-related recovery flows, and the same anti-enumeration principle applies here. The client can receive a generic result such as "If this account can receive a code, one has been sent," while an internal reason code records whether the attempt was suppressed, rate-limited, deduplicated, or queued. Three words matter: persist the reason.

Derive the transaction from the constraints

The request path has four invariants. One logical attempt gets one idempotency key. A suppressed recipient creates no send command. A committed challenge has a matching outbox record. Every eligibility decision remains reconstructable without storing the OTP itself in logs. Those invariants point toward a database transaction, rather than a synchronous call to an SMS gateway in the middle of the login handler.

The key can be derived from a server-issued login transaction identifier, not directly from a phone number. Within one transaction, lock or conditionally update the challenge row, check the normalized recipient's suppression record, and insert the outbox message only if eligible. A worker claims that record and calls a generic sender interface; provider latency is then outside the authentication database transaction. This does not promise exactly-once delivery, which an external network cannot provide. It provides exactly-once intent inside the service and idempotent handoff at the boundary.

A compact Go sketch makes the ordering visible. The implementation omits token generation and cryptographic storage details deliberately; those belong in a dedicated challenge component, where codes are random, short-lived, single-use, rate-limited, and stored as verifiers rather than plaintext.

package auth

import (
    "context"
    "errors"
    "time"
)

var ErrSuppressed = errors.New("recipient is not eligible")

type RecipientState struct {
    Status    string
    ExpiresAt *time.Time
}

type Store interface {
    InTx(ctx context.Context, fn func(Tx) error) error
}

type Tx interface {
    RecipientForUpdate(ctx context.Context, e164 string) (RecipientState, error)
    InsertChallengeOnce(ctx context.Context, loginID, verifier string, expiresAt time.Time) (bool, error)
    InsertOutbox(ctx context.Context, loginID, e164, template string) error
    AppendAudit(ctx context.Context, loginID, decision, reason string) error
}

func CreateChallenge(ctx context.Context, db Store, loginID, e164, verifier string, now time.Time) error {
    return db.InTx(ctx, func(tx Tx) error {
        state, err := tx.RecipientForUpdate(ctx, e164)
        if err != nil {
            return err
        }
        blocked := state.Status == "permanently_suppressed" ||
            (state.Status == "temporarily_suppressed" && state.ExpiresAt != nil && now.Before(*state.ExpiresAt))
        if blocked {
            return tx.AppendAudit(ctx, loginID, "suppressed", state.Status)
        }

        inserted, err := tx.InsertChallengeOnce(ctx, loginID, verifier, now.Add(5*time.Minute))
        if err != nil || !inserted {
            return err
        }
        if err := tx.InsertOutbox(ctx, loginID, e164, "login_code"); err != nil {
            return err
        }
        return tx.AppendAudit(ctx, loginID, "queued", "recipient_active")
    })
}
Enter fullscreen mode Exit fullscreen mode

The five-minute lifetime above is an application choice in the example, not a universal compliance limit. Set the real value through a threat model and applicable policy, and ensure resend never extends an old challenge indefinitely. Audit records should contain identifiers, timestamps, decisions, and policy versions; OTP values and message bodies do not belong there. Retention and access controls likewise follow the organization's legal and regulatory obligations, rather than an arbitrary engineering default.

Late delivery events need monotonic rules

Webhook processing is where otherwise careful designs become nondeterministic. Events can be duplicated or arrive out of order, so the handler should first verify authenticity according to the sender's contract, then insert the external event ID under a unique constraint. If the insert conflicts, acknowledge the duplicate without applying the transition twice.

Transitions must also be monotonic by severity and evidence. A delayed "accepted" event should not reactivate a number after a later permanent-invalid event has already been recorded. Store both provider event time and ingestion time, retain the raw event in access-controlled storage according to policy, and append a transition record containing the old state, new state, mapped reason, and mapping version. Reconciliation can then compare submitted messages, terminal delivery outcomes, suppression transitions, and unused challenges without guessing which callback won a race.

There is another boundary: a delivery failure should invalidate or quarantine the associated challenge, but it should not silently alter account ownership. Changing the phone number requires a separately authenticated workflow. This prevents an operations shortcut from becoming an account-takeover path.

What should operators compare?

The useful comparison is not a feature checklist. It is the evidence each layer can provide when a dispatcher says, "The code never arrived."

Layer Decision criterion Evidence to retain Failure response
Recipient state Can this normalized number receive a new challenge? reason, source event, effective time, expiry, policy version suppress internally; keep public response generic
Challenge store Is there already a live attempt for this login transaction? challenge ID, verifier hash, creation and consumption times reuse the recorded outcome; do not mint parallel codes
Transactional outbox Did committed intent reach the sending worker? outbox ID, claim attempts, terminal handoff status retry with bounded backoff under the same idempotency key
Delivery adapter What did the external channel report? authenticated event ID, mapped category, provider and ingestion times deduplicate, classify, then transition state
Reconciliation Do intent, handoff, outcome, and use agree? daily exception set and resolution audit investigate gaps; never infer delivery from submission alone

Measure ratios by reason code: suppression decisions, duplicate challenge requests, outbox age, callback lag, terminal failures, and challenges consumed. Alert on changes in the relationships, not on raw send volume alone. For example, growing outbox age with stable suppression rates points toward dispatch capacity, while a sudden rise in permanent-invalid classifications may indicate stale carrier contact data or a mapping error.

Email sender guidance reinforces the broader principle even though OTP is sent by SMS here: Yahoo's sender requirements tell senders to honor unsubscribes promptly and keep complaint rates low. Authentication messages and marketing consent are different domains, but recipient eligibility still needs explicit, durable handling. Do not merge a marketing opt-out record with an authentication delivery-invalid record merely because both can prevent a send; retain distinct purposes and legal bases.

Roll out without losing the audit trail

Begin in observe-only mode: normalize numbers, compute the proposed decision, and record it without blocking sends. Compare those decisions with terminal delivery evidence and review category mappings with security, operations, and compliance owners. No invented threshold can replace that review.

Next, enforce permanent suppression for the narrowest high-confidence invalid-recipient category, while keeping a controlled reactivation workflow and a kill switch. Then enable temporary suppression and expiry, followed by automated reconciliation. During migration, backfill only records whose provenance and meaning are known; an unexplained legacy blocked=true value should remain quarantined for review rather than being promoted into permanent policy.

The completion criterion is behavioral: repeated login requests produce one challenge intent, known-invalid numbers produce no new send intent, duplicate and reordered events converge on the same recipient state, and an operator can reconstruct every decision without seeing a code. That is a defensible transactional authentication flow.

Sources

References:

Top comments (0)