DEV Community

OrlandoJohansson7621
OrlandoJohansson7621

Posted on

NestJS SMS OTP Explained — Abuse Budgets, Audit Evidence, and Recovery for Game Reports

Short answer: treat SMS OTP as a single-use, server-side challenge with one atomic consume operation, layered throttling, redacted audit events, and separately generated recovery codes; only after that challenge succeeds should a gaming back office enqueue the generated report that will be sent as an email attachment.

The operational constraint changes the design. A login request is interactive, but report generation and email delivery are asynchronous. Joining them in one controller call creates an awkward failure boundary: a retry can consume another OTP, generate the same report twice, or send two attachments. The working method in this article is to write the failure-injection cases first, finish authentication before admitting work, and make one durable report command carry an idempotency key.

This is a control-flow problem before it's an SMS problem.

Write the failure matrix before choosing the adapter

Consider a game operations console where an analyst requests a daily economy report. The attachment may contain player-level data, so the action requires step-up authentication. Before wiring a controller to an SMS API, write down what a retry is allowed to repeat. This turns integration effort into something testable: every state transition that crosses a process boundary needs an owner, a stable identity, and a terminal result.

The first drill is a partial success. The OTP is accepted, the report command is committed, and the client loses the response. It retries. If the backend treats the second HTTP request as new work, the analyst may receive duplicate attachments even though authentication behaved correctly. A second drill terminates the process after the database commit but before queue publication. A third races two valid submissions for the same challenge. The pass criteria are sharper than “the code was valid”: one challenge is consumed once, one authorized business action is admitted once, and committed work remains discoverable after restart.

I initially reach for a queue when I see email, but a queue alone doesn't establish those properties. The database transaction is the authority. The queue moves work after the transaction; it cannot retroactively make a non-atomic verification safe.

A compact test matrix keeps the implementation honest:

Injection Required observation Forbidden result
Two correct codes race One accepted audit event Two consumed grants
Response disappears after report commit Retry returns the original report identity A second attachment command
Worker stops before publication Outbox age rises and work is retried Lost committed work
Code arrives after expiry One denied outcome A fresh grant

Only then map the request flow:

  1. POST the login step to create a challenge. Return an opaque challenge identifier, not the code's database key or any delivery detail that reveals whether an account exists.
  2. Submit the challenge identifier and code. In one transaction, lock or conditionally update the live challenge, check its attempt budget and deadline, then mark it consumed.
  3. Mint a short-lived authorization grant scoped to generate:economy-report. Do not treat the OTP value itself as that grant.
  4. Accept the report command with a client-supplied idempotency key. Commit an outbox record beside it, then acknowledge the request.
  5. Let a worker render the attachment, send it, and record the provider message identifier without ever reopening the OTP challenge.

Keep those identities separate: challenge_id, authorization_id, report_id, and delivery_id. It makes an audit query readable at 03:00, and it prevents a provider retry from becoming an authentication retry.

How should a backend throttle SMS OTP authentication and audit recovery codes?

Throttle issuance and verification independently. Issuance spends messaging capacity and can harass the owner of a phone number; verification spends guesses against a live secret. One counter cannot express both risks. An illustrative policy has a small per-challenge attempt budget plus rolling budgets for account, normalized destination, device or session, and coarse network source. The exact limits are local policy, not universal constants. They need tuning from observed legitimate traffic and abuse reviews.

Return HTTP 429 with a retry delay when a request budget is exhausted, and keep the public error shape stable for unknown accounts and known accounts. Internally, preserve the reason: issue_account_budget, issue_destination_budget, verify_challenge_budget, or verify_network_budget. That distinction is runbook material. It tells the on-call engineer whether one user is mistyping, one source is spraying accounts, or a campaign is targeting a phone number.

Don't log the OTP.

An audit event should carry the event name, outcome, subject or pseudonymous account key, challenge ID, authentication method, request correlation ID, policy version, coarse source context, and an RFC 3339 timestamp. It should not carry the SMS body, plaintext code, recovery code, session token, or full phone number. Audit storage also needs an explicit retention and access policy; “append-only” is useful behavior, not permission to retain sensitive context forever.

Recovery is a separate authenticator path. Generate a set of high-entropy random codes, show them once, store only keyed digests, and atomically consume one code on use. A recovery event should revoke or rotate the remaining set according to your policy and notify the account through an already verified channel. It must not silently bypass the same report-action audit trail. The catch is that recovery improves availability by creating another credential to protect, so an organization that cannot support secure display, storage guidance, rotation, and review should prefer assisted recovery with strong identity checks rather than bolt on weak reusable answers.

I'm not sure which network budget will fit your player population; carrier NAT, corporate egress, and travel can make IP-only limits punish legitimate users. Account and challenge limits should do the security work, while network signals add friction and investigation context rather than act as the sole lock.

Encode the verification contract as one state transition

The core is a state transition, not a controller decorator. NestJS can handle transport, validation, and dependency injection, but the verification operation sits behind a narrow repository interface so its atomic behavior can be tested without an SMS provider. The following Go sketch expresses the executable contract because the state machine matters more than framework syntax. The numeric values are example policy inputs, not recommendations.

package otp

import (
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "errors"
    "time"
)

var (
    ErrDenied    = errors.New("challenge denied")
    ErrExhausted = errors.New("attempt budget exhausted")
)

type Challenge struct {
    ID         string
    AccountID  string
    CodeMAC    []byte
    ExpiresAt  time.Time
    Attempts   int
    MaxAttempts int
    ConsumedAt *time.Time
}

type Store interface {
    // WithChallenge must lock the row or provide an equivalent serializable update.
    WithChallenge(ctx context.Context, id string, fn func(*Challenge) error) error
}

type AuditSink interface {
    Append(ctx context.Context, event Event) error
}

type Event struct {
    Name       string
    Outcome    string
    AccountID  string
    ChallengeID string
    Policy     string
    OccurredAt time.Time
}

type Verifier struct {
    Store  Store
    Audit  AuditSink
    MACKey []byte
    Now    func() time.Time
}

func codeMAC(key []byte, challengeID, code string) []byte {
    m := hmac.New(sha256.New, key)
    m.Write([]byte(challengeID))
    m.Write([]byte{0})
    m.Write([]byte(code))
    return m.Sum(nil)
}

func (v Verifier) Verify(ctx context.Context, challengeID, code string) error {
    now := v.Now()
    var event Event

    err := v.Store.WithChallenge(ctx, challengeID, func(c *Challenge) error {
        event = Event{
            Name: "sms_otp_verify", AccountID: c.AccountID,
            ChallengeID: c.ID, Policy: "otp-v3", OccurredAt: now,
        }

        if c.ConsumedAt != nil || !now.Before(c.ExpiresAt) {
            event.Outcome = "denied"
            return ErrDenied
        }
        if c.Attempts >= c.MaxAttempts {
            event.Outcome = "budget_exhausted"
            return ErrExhausted
        }

        c.Attempts++
        if !hmac.Equal(c.CodeMAC, codeMAC(v.MACKey, c.ID, code)) {
            event.Outcome = "denied"
            return ErrDenied
        }

        c.ConsumedAt = &now
        event.Outcome = "accepted"
        return nil
    })

    // In production, commit the audit event through the same transaction or an outbox.
    if event.Name != "" {
        _ = v.Audit.Append(ctx, event)
    }
    return err
}
Enter fullscreen mode Exit fullscreen mode

There is a deliberate warning in that example: ignoring an audit write error is acceptable only if Append feeds a durable transactional outbox whose commit is coupled to the challenge update. If it is a remote best-effort call, the code creates an evidence gap. Make the repository return both the state transition and an outbox event in one commit, then publish asynchronously. Tests should race two correct submissions and assert that exactly one consumes the challenge; they should also cover expiry at the boundary, a wrong code on the final allowed attempt, replay after success, and audit redaction.

Use a cryptographically secure random generator for OTP and recovery material. Bind the stored MAC to the challenge identifier and a server-held key, compare MACs in constant time, and rotate keys with an explicit version field. A plain fast hash of a six-digit code is not enough protection if the challenge table is copied because the search space is intentionally small. This is also why the raw value has no place in traces.

Replay the contract through report generation and email

After verification, the report endpoint should consume a scoped authorization grant and insert the report command under a uniqueness constraint such as (account_id, idempotency_key). The same transaction writes an outbox event. A dispatcher can publish repeatedly; the worker still claims the report by its durable state and records one logical delivery. Retries are expected. Duplicates are contained.

Scheduling belongs here because reports often have deadlines. Store requested_at, not_before, expires_at, and the report's business date as data rather than burying them in a delayed callback. A worker that starts after expires_at should mark the command expired and emit an operational event; it should not send stale player data merely because the queue eventually delivered the message. For recurring reports, derive a stable idempotency key from the schedule identity and business window, not from the wall-clock time at which a worker woke up.

The attachment path needs its own controls: authorize access to source data, render into bounded temporary storage, validate media type and size before handing it to the mail adapter, and delete temporary material under a defined lifecycle. Keep the report hash and template version in the delivery record if they help establish what was sent, but don't put report contents into the general audit stream. Mustache's default escaped interpolation is useful for text-oriented templates; triple braces insert unescaped content, so they deserve explicit review rather than casual use.

Observability should follow the business states. Count challenges issued, accepted, denied, exhausted, and expired; report commands admitted, deduplicated, started, expired, and delivered; and outbox age. Alert on sustained age or a missing terminal state, not on every failed OTP. A burst of denials can be abuse, user error, or automation. The labels and correlated audit events provide the diagnosis.

When should you choose a different second factor or delivery design?

SMS OTP is not suitable when the threat model requires phishing resistance. NIST treats use of the public switched telephone network for out-of-band authentication as a restricted authenticator and asks verifiers to consider risks such as SIM change and number porting. For privileged game administration, regulated data, or accounts targeted by capable attackers, use a phishing-resistant authenticator and keep SMS, if permitted by policy, as a constrained fallback rather than presenting all factors as equivalent.

Stick with an in-process report job only when loss on restart and duplicate execution are acceptable, which is rare for player-data attachments. A database-backed outbox adds schema, a dispatcher, cleanup, and lag monitoring; it is unnecessary for a disposable notification with no business consequence. Likewise, assisted account recovery may be the better choice for a small, high-value operator population, while self-service recovery codes fit systems that can support secure enrollment and rotation.

Integration effort is more than lines in a NestJS module. Count the contracts that must survive failure: atomic challenge consumption, coupled audit evidence, several abuse budgets, an isolated SMS adapter, and single admission of the report. Exercise each one under concurrent verification and retry. A shorter provider call doesn't compensate for an ambiguous state transition.

The decision rule is plain: choose the smallest design that preserves single consumption, single admission, and reviewable evidence for the consequence you actually care about. Here, that consequence is one authorized game report sent once.

References

Top comments (0)