DEV Community

DemetriusReed2163
DemetriusReed2163

Posted on

Auditing Blocked Numbers Before Receipt Approval (SMS OTP and 2FA Suppression)

A settled payment should produce a receipt only after two independent decisions: the payment event is valid, and the destination is eligible. In a Node.js transactional auth flow, an SMS OTP used for 2FA needs a suppression-list lookup before any code is generated. Record both decisions with stable event identifiers. That is the shortest path to useful compliance evidence.

TL;DR: make suppression a fail-closed policy dependency, not an error discovered after an SMS attempt. Store a digest of each one-time code, bind it to one approval and one recipient, enforce expiry and attempt limits, consume it atomically, and keep the audit event free of the code itself. OWASP recommends cryptographically random, securely stored, single-use, expiring tokens plus rate limiting. Those properties are the floor for this auth flow.

What should happen when payment settles?

The data flow is small. A marketplace payment-settled event enters an idempotent worker. The worker resolves the order, payer, receipt destination, and any B2B approval still required. If approval is required, it normalizes the phone number, asks the suppression policy for an authoritative decision, and creates a 2FA challenge only when sending is allowed. Successful verification releases the receipt job. Every transition appends an audit record keyed by the payment, order, approval, and message intent.

Don't let a notification callback become the source of truth for settlement. Retries and duplicate events are normal. The receipt workflow needs a unique settlement-event key and a separate message-intent key; otherwise a harmless replay can create another challenge or another receipt.

The boundary also prevents an awkward failure: generating and storing an OTP, then learning that the number is blocked. That leaves an active credential the user could never legitimately receive. Check first.

How should Node.js check an SMS OTP suppression list for 2FA?

This example uses only the Python standard library. The in-memory stores expose the state transitions; replace them with transactional tables and a shared rate limiter in production. The five-minute lifetime and five-attempt cap are application choices to tune with an eval harness, not values prescribed by OWASP.

from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets

TTL = timedelta(minutes=5)
MAX_ATTEMPTS = 5
PEPPER = b"load-from-a-secret-manager"
SUPPRESSIONS = {
    "+15550100001": {"reason": "recipient_opt_out", "active": True},
    "+15550100002": {"reason": "invalid_destination", "active": True},
}
CHALLENGES = {}
AUDIT = []


def utcnow():
    return datetime.now(timezone.utc)


def digest(challenge_id, code):
    payload = f"{challenge_id}:{code}".encode()
    return hmac.new(PEPPER, payload, hashlib.sha256).hexdigest()


def audit(event_type, **fields):
    AUDIT.append({
        "event_type": event_type,
        "occurred_at": utcnow().isoformat(),
        **fields,
    })


def create_challenge(order_id, payment_event_id, phone_e164):
    policy = SUPPRESSIONS.get(phone_e164)
    if policy and policy["active"]:
        audit(
            "approval_send_blocked",
            order_id=order_id,
            payment_event_id=payment_event_id,
            reason=policy["reason"],
        )
        return {"status": "blocked", "reason": policy["reason"]}

    challenge_id = secrets.token_urlsafe(18)
    code = f"{secrets.randbelow(1_000_000):06d}"
    CHALLENGES[challenge_id] = {
        "order_id": order_id,
        "code_digest": digest(challenge_id, code),
        "expires_at": utcnow() + TTL,
        "attempts": 0,
        "consumed_at": None,
    }
    audit(
        "approval_challenge_created",
        challenge_id=challenge_id,
        order_id=order_id,
        payment_event_id=payment_event_id,
    )
    return {"status": "send", "challenge_id": challenge_id, "code": code}


def verify(challenge_id, submitted_code):
    row = CHALLENGES.get(challenge_id)
    if row is None or row["consumed_at"] is not None:
        return False
    if utcnow() >= row["expires_at"] or row["attempts"] >= MAX_ATTEMPTS:
        return False

    row["attempts"] += 1
    accepted = hmac.compare_digest(
        row["code_digest"], digest(challenge_id, submitted_code)
    )
    if accepted:
        row["consumed_at"] = utcnow()
        audit(
            "approval_challenge_consumed",
            challenge_id=challenge_id,
            order_id=row["order_id"],
        )
    return accepted
Enter fullscreen mode Exit fullscreen mode

The returned code crosses the boundary into a generic SMS adapter; it must never be returned to an end-user client. It appears in the example response only to make the handoff testable without inventing a commercial API. In a deployed service, pass it directly to the adapter and return a uniform user-facing response. OWASP also advises consistent messages and timing so account state isn't exposed. There is a concrete race to close here: a recipient can opt out after the challenge row is created but before the queue worker sends it. The worker therefore repeats the policy check, records the newer decision identifier, and cancels the intent if the number has become suppressed. It doesn't create a replacement code. This second lookup costs another policy read and complicates the state machine, but checking only once leaves a compliance gap exactly where asynchronous queues make timing least predictable.

A Node.js worker can implement the same contract. The important part isn't the runtime: check suppression, persist the digest and audit event transactionally, and enqueue only the resulting approved intent. Keep the adapter interface narrow enough that tests can assert those steps without sending an SMS.

Why isn't suppression just a set of numbers?

A blocked-number list needs provenance. At minimum, retain a normalized destination, scope, reason, source event, effective time, and revocation time. An explicit recipient opt-out shouldn't be silently cleared because a later delivery succeeds. An invalid-destination signal may follow a different review policy. Legal requirements vary by jurisdiction and message category, so counsel must define retention and consent rules; the code should preserve the evidence needed to apply them.

The lookup belongs immediately before enqueueing the SMS. A check performed when the order was created can become stale before settlement. Put a version or decision identifier from the suppression service into the audit event, then let the sender accept only a short-lived, policy-approved intent. No approval means no send.

The same principle extends to receipt email without pretending the channels are identical. Yahoo's sender guidance calls for valid authentication, low complaint rates, and easy unsubscribe behavior for subscribed mail. A transactional receipt and promotional reminders have different purposes, but accurate classification, authenticated sending, and complaint handling still belong in the operational design.

Make the evidence answer real questions

An audit trail is useful when an investigator can reconstruct the decision without seeing secrets or unnecessary personal data. For each transition, record the actor or service, UTC timestamp, correlation identifiers, policy outcome, reason code, and immutable event type. Avoid OTP plaintext, message bodies, and full phone numbers in general logs. Restrict the sensitive identity mapping behind purpose-based access and a defined retention schedule.

I would turn those requirements into eval cases before connecting a sender. Replay the same settlement event twice and expect one message intent. Activate suppression between challenge creation and enqueue and expect no send. Submit a correct expired code and expect rejection. Race two correct submissions and expect one consumption. Exhaust the attempt limit. Finally, verify that logs contain neither the code nor the full destination.

Six cases.

Each has one observable result, which is far more useful than a notebook demo that proves only the happy path. The numeric choices deserve the same scrutiny: a 5-minute expiry and 5-attempt cap are starting assumptions for this example, not universal security constants. The eval report should show their effect on completion, expiration, and lockout behavior before a production decision is made.

Measure policy decisions separately from delivery outcomes. Useful counters include challenges blocked by reason, challenges created, verification failures, expirations, duplicate settlement events, receipt jobs released, and receipt delivery callbacks. Raw counts help during deployment; ratios need enough traffic context to avoid noisy alerts.

Operate the whole path

This design has limitations. SMS OTP isn't suitable when the intended approver cannot reliably access the registered phone, and a suppression decision can intentionally block completion. A recovery flow should require independently verified access rather than bypassing the list. The trade-off is deliberate: stronger evidence and recipient control can add friction to urgent B2B reminders. Teams that require phishing-resistant authentication should evaluate a stronger authenticator instead of treating SMS as equivalent.

Before release, make uniqueness constraints enforce idempotency at the database layer, place challenge creation and audit append in one transaction, and make successful consumption a conditional update. Confirm that the suppression dependency fails closed and has an explicit recovery path for authorized staff. Rotate the digest secret with a version field, limit access to destination data, and test clock boundaries.

Deploy in stages: shadow the policy decision, compare it with expected outcomes, enable challenge creation without external sending, and finally enable a small controlled cohort. Keep the receipt queue independent from the authentication transport so an SMS delay can't rewrite payment state. This separation also makes costs legible: model or prompt spend is irrelevant to the deterministic security path, and notification volume can be forecast from message intents instead of API calls.

The decision rule stays compact: settlement establishes that a receipt is due; policy establishes whether a destination may be used; successful, atomic approval establishes that the receipt can be released. Evidence connects those facts. A transport provider can't supply that chain on the application's behalf.

References

Top comments (0)