DEV Community

IronspireDraven77
IronspireDraven77

Posted on

2026 NestJS Customer Support Backend for SMS OTP Abuse Budgets and Audit

A NestJS two-factor authentication backend using SMS has one hard operational job: decide, under retry pressure, which state transition is still allowed and which event should wake someone up.

Short answer: put SMS OTP issuance, verification, throttling, recovery-code consumption, recipient suppression, and audit emission behind one backend-owned challenge state machine; let NestJS controllers translate HTTP requests, but never let controllers or a messaging callback invent authentication state.

For a customer-support system, this boundary matters twice. Agents need a second factor to enter accounts containing customer conversations, while invalid phone numbers and repeated delivery failures must not produce an endless send loop. The design below treats a send as an effect of an accepted challenge transition, not as proof that a person received anything.

No dashboard settles that distinction.

Governance starts with an evidence ledger

Consider a bounded incident rather than a success-path demo. An agent requests a login code, taps resend twice, receives the first message after the third request, and enters that older code. Meanwhile, the messaging adapter classifies the destination as invalid and the recipient-suppression worker consumes that result. If the controller owns the counters, the callback owns recipient status, and a separate recovery endpoint owns bypass codes, three locally reasonable decisions can disagree about whether the login challenge is usable.

The useful page is not "SMS volume increased." That page describes spend and traffic, not impact. The page should fire when the authentication state machine rejects or stalls legitimate progress beyond an agreed service objective, when a suppression decision changes an agent's reachable factor, or when recovery consumption rises unexpectedly. I would want the alert payload to contain a challenge ID, a non-reversible subject reference, the transition that failed, the policy reason, and the correlation ID. I would not put a phone number, OTP, or recovery code in it.

That framing exposes the invariant: only one component may decide whether a challenge moves from pending to verified, exhausted, expired, or cancelled. Delivery callbacks supply evidence. They don't authenticate anybody. A terminal invalid-recipient classification can suppress later sends to that destination and cancel an outstanding challenge according to policy; a transient delivery result can remain observable without rewriting identity state. Provider-specific result codes belong in the adapter, where they are mapped into the small vocabulary understood by the state machine.

I'm not sure which invalid-recipient signals your carrier mix will make trustworthy without sampling real callback data; your mileage may vary across countries and routes. That uncertainty is a reason to keep raw provider evidence in a restricted delivery record and version the classification rule, not a reason to let raw strings leak into authorization logic.

How should a NestJS backend throttle SMS OTP attempts and audit recovery?

Use separate budgets for issuance and verification. Issuance limits messaging abuse; verification limits guessing. Key both to more than one dimension β€” for example, account plus destination for issuance and challenge plus account for verification β€” so a single rotating input does not erase the entire control. The exact windows and counts are policy choices that must come from threat modeling and observed traffic, not from a copied tutorial.

Make the acceptance decision and counter update atomic. In a NestJS deployment with several processes, an in-memory counter is a test double, not the production authority, because two workers can both observe capacity and admit a send. The same rule applies to recovery codes: store a one-way verifier, consume exactly one code in the same transaction that marks the challenge recovered, and reject reuse without revealing whether the submitted value was once valid.

Short-lived codes are still secrets.

The core can sit behind a NestJS injectable service even though this reference implementation is in Go; the point is the contract, which should remain unchanged when the HTTP framework or messaging provider changes. The store implementation must provide compare-and-swap or transactional semantics for Update, and the sender adapter must return only a normalized delivery classification.

package otp

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

type Status string

const (
    Pending   Status = "pending"
    Verified  Status = "verified"
    Exhausted Status = "exhausted"
    Cancelled Status = "cancelled"
)

var (
    ErrDenied  = errors.New("challenge denied")
    ErrExpired = errors.New("challenge expired")
)

type Challenge struct {
    ID              string
    SubjectRef      string
    DestinationRef  string
    CodeVerifier    []byte
    ExpiresAt       time.Time
    AttemptsLeft    int
    Status          Status
}

type Event struct {
    ChallengeID string
    SubjectRef  string
    Kind        string
    Reason      string
    OccurredAt  time.Time
    Correlation string
}

type Store interface {
    // Update commits the state change and audit event atomically.
    Update(ctx context.Context, id string, apply func(*Challenge) (Event, error)) error
}

type Verifier interface {
    Match(stored []byte, submitted string) bool
}

type Service struct {
    store  Store
    verify Verifier
    now    func() time.Time
}

func (s *Service) Verify(ctx context.Context, id, code, correlation string) error {
    return s.store.Update(ctx, id, func(c *Challenge) (Event, error) {
        now := s.now()
        e := Event{
            ChallengeID: id,
            SubjectRef:  c.SubjectRef,
            OccurredAt:  now,
            Correlation: correlation,
        }

        if c.Status != Pending {
            e.Kind, e.Reason = "otp.verify_denied", "challenge_not_pending"
            return e, ErrDenied
        }
        if !now.Before(c.ExpiresAt) {
            c.Status = Cancelled
            e.Kind, e.Reason = "otp.expired", "deadline_reached"
            return e, ErrExpired
        }
        if c.AttemptsLeft <= 0 {
            c.Status = Exhausted
            e.Kind, e.Reason = "otp.verify_denied", "attempt_budget_exhausted"
            return e, ErrDenied
        }

        c.AttemptsLeft--
        if !s.verify.Match(c.CodeVerifier, code) {
            if c.AttemptsLeft == 0 {
                c.Status = Exhausted
            }
            e.Kind, e.Reason = "otp.verify_denied", "code_mismatch"
            return e, ErrDenied
        }

        c.Status = Verified
        c.CodeVerifier = nil
        e.Kind, e.Reason = "otp.verified", "code_matched"
        return e, nil
    })
}
Enter fullscreen mode Exit fullscreen mode

The HTTP layer can return one generic denial shape for mismatches, exhausted challenges, and already-consumed challenges while logging the internal reason as structured, access-controlled evidence. Don't let a more descriptive response become an account-enumeration side channel. Also keep the correlation ID separate from the idempotency key: the former joins evidence across components, while the latter prevents a retried issuance command from creating a second message effect.

For issuance, use an outbox record committed with the new challenge. A worker claims that record, calls the messaging adapter, and records the normalized outcome. This closes the awkward gap where the database commits but the process exits before sending. It also gives resend logic something concrete to inspect: the backend can return the existing accepted challenge for the same idempotency key instead of manufacturing another code.

An audit event should answer who attempted what transition, against which opaque subject and challenge, under which policy version, and with what result. It should not reproduce request bodies. That distinction keeps codes and phone numbers out of ordinary logs while preserving enough evidence to reconstruct the decision path after a lockout report.

I start the postmortem timeline from accepted commands and committed transitions, then join delivery evidence by correlation ID. Message-provider timestamps are useful observations, but they are not the clock that decides expiry; backend time is. A dashboard may plot sends, denials, suppressions, and recoveries, yet the source of truth remains the ordered event record plus current challenge state. Ask what page fired, then verify that the event needed to explain it actually exists.

Recovery deserves its own event kinds and budget. Generate recovery codes through a cryptographically secure mechanism, show them once, retain only one-way verifiers, and invalidate a code on successful consumption. A recovery event should identify the credential set version and outcome, never the submitted code. Rotating the set should invalidate the previous set in one transaction so two browser tabs cannot preserve an old escape hatch.

Template rendering is another boundary worth shrinking. If Mustache is used for an SMS body, pass a small view containing the display-safe code and expiry copy, and review whether escaped or unescaped tags are appropriate for the actual channel. Authentication policy does not belong in the template. If an AI support agent can invoke an OTP tool, its tool definition and input schema can constrain the command shape, but the model must still cross the same backend authorization, throttle, and audit path as every other caller. A schema is not permission.

Developer experience, ownership cost, and the cases to decline

Evidence received Component that interprets it Allowed state-machine effect
Duplicate issuance command Command service Return the prior accepted challenge
Normalized invalid recipient Suppression policy Block later sends and apply the configured challenge policy
Incorrect submitted code Verification service Consume one verification attempt
Valid unused recovery code Recovery service Atomically consume the code and recover the challenge

The table is deliberately small. Carrier result strings, HTTP response details, and template variables stay outside this vocabulary; adding each transport detail to the authentication core increases integration effort and makes policy changes harder to review.

The catch is integration effort. A transactional store, outbox worker, callback normalizer, suppression table, recovery verifier, and event pipeline create more moving parts than a single-process prototype. This design is not suitable when the application is a disposable demonstration with no real identities, no delivery callbacks, and no operational ownership; a bounded in-memory example can be clearer there, provided nobody mistakes it for a deployment design.

SMS is also the wrong factor when the risk model or regulatory requirements demand phishing-resistant authentication. In that case, choose an authenticator built for that property and keep recovery aligned with it rather than polishing the SMS path. If the organization cannot operate an outbox and atomic state store, stick with a managed authentication system whose documented boundaries match the required controls; keep the application integration narrow and test the failure contracts. The right decision axis is the integration your team can actually observe at 3 a.m., not the shortest setup snippet.

Migration gates for an existing NestJS service

Before rollout, test concurrent resend commands with the same idempotency key, simultaneous verification attempts at the last remaining try, expiry at the time boundary, duplicate callbacks, a terminal invalid-recipient classification, suppression removal through an authorized support workflow, and two consumers racing on one recovery code. Deployment should begin with shadow audit events and metrics that reveal unexpected policy decisions before enforcement changes login outcomes. Then page on user impact and invariant violations.

Quiet is not proof.

Sources

Top comments (0)