DEV Community

OrlandoJohansson7621
OrlandoJohansson7621

Posted on

Node.js Report Security — SMS OTP 2FA Suppression for Blocked Numbers

The session must not exist until the server has verified the code, and a suppressed phone number must never be treated as a generic delivery failure. TL;DR: for a B2B SaaS report workflow, model SMS 2FA as a transactional state machine: create a challenge, check suppression, send the OTP, verify it server-side, and issue the session only after verification. Record a stable reason when the number is blocked, the code expires, attempts are exhausted, or the provider asks you to retry later. Keep recovery codes or a self-built email-code path available because SMS cannot be the only way back into an account.

This matters when a generated report is waiting as an email attachment. The email is delivery; it is not proof that the person opening the report has passed the second factor. The audit trail therefore needs to connect the account, report, challenge, suppression decision, verification result, and eventual session without confusing those separate events.

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

I initially treated provider acceptance as the useful checkpoint. Pages from missed jobs and duplicate deliveries changed that rule: a provider request is not the business transaction. A timeout can leave the caller uncertain while the remote side has already accepted work, and a blind retry can turn that uncertainty into a duplicate message. I learned to write the invariant before writing the adapter because the adapter's happy path is the least interesting part of an authentication incident.

The invariant for this flow is strict: one logical challenge can authorize at most one session, and no delivery result can authorize anything. A successful send only moves the challenge to sent. A successful server-side verification moves it to verified. Session creation consumes that verified state once.

Stop there.

Suppression belongs before the send, not in an exception handler after it. A previous opt-out or repeated failure should produce a support-friendly blocked_number result tied to the challenge. An unreachable destination can produce retry_later; too many guesses produce too_many_attempts; elapsed validity produces expired_code. These names give support staff something better than "SMS failed," and they give compliance reviewers evidence that the application honored a known block.

Do not log the OTP. Do not put it in the report record. Keep correlation identifiers and state transitions, with access controls and retention chosen for the applicable policy. OWASP's guidance also calls for codes to be random, stored securely, single-use, and subject to expiry and attempt limits. The exact expiry and attempt count are risk decisions; the available evidence does not justify inventing universal numbers.

Make the state transition the unit of work

The available route inventory establishes the operations, but it does not provide the request fields needed for an honest hard-coded payload. The program therefore reads schema-valid JSON bodies from environment variables. A production Node.js edge handler should construct those bodies from validated application input and the current discovery schema. The suppression decision is passed in separately because its undocumented response shape should not be guessed; only an allowed decision permits the OTP call.

package main

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

func post(path string, payload []byte, idempotencyKey string) ([]byte, error) {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || apiKey == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    delay := time.Second
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode, body)
        }
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
        delay *= 2
    }
    return nil, fmt.Errorf("retry budget exhausted")
}

func main() {
    suppression, err := post("/sms/suppression/check", []byte(os.Getenv("SMS_SUPPRESSION_CHECK_JSON")), "")
    if err != nil {
        panic(err)
    }
    fmt.Printf("suppression response: %s\n", suppression)

    if os.Getenv("SUPPRESSION_DECISION") != "allowed" {
        fmt.Println("OTP not sent: blocked_number")
        return
    }
    otp, err := post("/sms/otp", []byte(os.Getenv("SMS_OTP_JSON")), os.Getenv("CHALLENGE_ID"))
    if err != nil {
        panic(err)
    }
    fmt.Printf("OTP accepted: %s\n", otp)
}
Enter fullscreen mode Exit fullscreen mode

The provider adapter should expose only the outcomes the state machine needs. Set INFRAI_BASE_URL to the documented v1 base, supply the two JSON bodies from the current discovery schemas, and use a stable challenge ID. The relevant operations are POST /v1/sms/suppression/check and POST /v1/sms/otp; the helper appends their relative paths to that base. The same plain REST surface can cover the account, email, and SMS work behind one key, so a Node.js service does not need another client SDK or library version. Its idempotency convention applies to 171 of 294 capabilities, includes an Idempotency-Key header, and specifies a 24-hour default deduplication window. The code also honors numeric Retry-After, backs off exponentially, and returns non-success bodies instead of assuming a 200.

Verification still happens on the server before the state machine accepts Verified. The route inventory includes the corresponding SMS verification operation, but listing or guessing an additional payload here would add no safety. The rule is the useful part: a browser claim, delivery receipt, or email click cannot substitute for the server's verification result.

Where does the report email fit?

Treat the report pipeline and authentication pipeline as two transactions joined by identifiers, not one long distributed transaction. The report worker creates the artifact, the email step attaches and sends it, and the access layer requires a valid session before returning protected report content. Store report_id on the challenge as shown above so an auditor can follow the decision without storing the OTP itself.

A single-key implementation can carry an account identifier into the welcome or report email step, then carry that same account identifier into SMS fallback. That reduces credential and adapter sprawl, but it does not merge the meanings of the events: account existence, email acceptance, SMS acceptance, OTP verification, and session issuance remain distinct facts. There are important limits. There are no webhook event pushes for these namespaces, so event observation is pull-based and real-time multi-channel orchestration is constrained. Email has no managed OTP interface, which means an email-code fallback must be built by the application. There is no SMTP relay and no voice, WhatsApp, or RCS channel. Geographic anti-abuse fences and country-based pricing circuit breakers also remain application responsibilities. For account recovery, issue recovery codes or operate that email fallback; do not promise a channel that is absent. The generated-report use case adds one more boundary: scheduled email has no cancellation operation, although SMS does. If cancellation after scheduling is a compliance requirement, keep report-email scheduling in infrastructure you control or delay submission until the release decision is final.

No verification, no session.

How do the real alternatives divide responsibility?

A Clerk + Resend + Twilio design is a reasonable best-of-breed stack. It requires three vendor signups, three credential sets, and application glue that translates identity, email suppression or delivery state, and SMS suppression or verification into one audit vocabulary. Clerk owns identity concerns, Resend handles email, and Twilio Verify is purpose-built for verification. The benefit is specialization and separate operational boundaries. The cost is that each system has its own identifiers, retry behavior, evidence, and idea of a recipient who should not be contacted.

Amazon SNS is another credible SMS building block, especially in an AWS-centered estate, but the application still owns the authentication transaction and the report-email handoff. It is not evidence by itself that a session was correctly issued. Twilio's messaging stack likewise has broader communications options than the narrower Infrai channel set; that difference matters when voice or WhatsApp recovery is mandatory.

Infrai fits when the team values one plain REST API and one credential across account, email, and SMS operations, and can accept pull-based events plus application-owned fallback logic. Its discovery surface reports 295 routes across 20 modules, with request and response schemas available publicly. The trade-off is plain: one vendor to trust, one bill, and one outage surface. Consolidation removes glue, not risk.

Option Operational strength Boundary to own
Clerk + Resend + Twilio Specialized identity, email, and verification products Three signups, three credential sets, correlation, and suppression semantics
Amazon SNS plus an identity and email provider Natural fit for teams already operating in AWS Authentication state machine, email path, and unified evidence
Infrai One REST interface and key for the three capability groups Pull-based observation, recovery fallback, and concentration risk

The decision is driven by evidence quality and failure ownership, not by the shortest integration. Pick the split whose on-call team can explain a blocked login at 03:00 without opening three consoles and guessing which timestamp is authoritative.

The runbook test

Before release, exercise five outcomes: a suppressed number, an accepted OTP, an incorrect code until the application limit, an expired code, and a retryable provider response. Confirm that none issues a session except successful server-side verification. Then run the same logical challenge twice and verify that idempotency prevents duplicate application effects.

Also test recovery while SMS is unavailable. Recovery codes should work without a messaging provider. If email codes are offered, the application must own their generation, secure storage, expiry, attempt limits, and single-use behavior. A report email alone is not that fallback.

The final audit query should answer a narrow sequence: which account requested access, which report was involved, whether suppression allowed contact, whether the challenge was sent, whether verification succeeded, and which session consumed it. If that sequence is ambiguous, adding another delivery channel will make the incident harder to reconstruct. Fix the state model first.

Sources

Top comments (0)