DEV Community

VelvetDusk629047
VelvetDusk629047

Posted on

Support Queue Step-Up: 4 SMS OTP Controls for Throttling and Audit Logs

Short answer: for a B2B SaaS contact form that can open a billing or security ticket, keep queue routing separate from authentication, and let the NestJS backend own four controls: challenge expiry, layered throttling, atomic one-time consumption, and secret-free audit events. SMS OTP can be the step-up factor, but recovery codes need an independent path so a lost phone does not become a permanent lockout.

Start with the responsibility boundary. It determines both integration effort and what your team must operate.

Approach Pick this when Your backend owns Main limitation
Application-managed challenge Queue routing has custom policy and the team can operate challenge state OTP generation, delivery adapter, throttling, verification, recovery, audit Largest implementation and review surface
Managed verification boundary You want to keep routing local while delegating message delivery and challenge checking Intent binding, authorization, queue decision, normalized audit events Provider behavior must be mapped into your event model
Identity-layer step-up The contact form is already behind a central identity boundary Queue policy and verification-context checks Awkward when anonymous or partially authenticated contacts are valid

No row wins universally. The rest of this guide goes deep on the first because it exposes the mechanics that still matter at either of the other boundaries.

Boundary first.

Pick the boundary before writing the controller

Treat the contact form and the second factor as two connected state machines, not one big endpoint. The form begins as a draft with a requested queue such as billing_review; the authentication flow creates a challenge bound to the account and the intended action; verification produces a narrow authorization result; only then may the routing service create the support case. Diagrammed in words: form draft -> challenge issued -> challenge verified -> routing policy evaluated -> case created.

That separation prevents a successful code from becoming a reusable all-purpose credential. A challenge for submit_support_form:form_7F2 should authorize that intent, not a profile edit or a second form. It also keeps queue selection out of the SMS message. The message carries a code. The server retains the account, form, intent, and queue context.

Application-managed challenges fit when routing rules are unusually specific: perhaps billing submissions require step-up while ordinary product questions do not, or account ownership changes which queue may receive the case. The integration cost is real. Your team must review randomness, secret storage, concurrent verification, retries, abuse controls, retention, and recovery.

A managed verification boundary reduces that surface. Keep a small adapter with operations such as sendChallenge and checkChallenge, and translate its result into your own domain event. Don't let provider response shapes leak into SupportRoutingService; changing a delivery boundary should not rewrite the queue policy.

Identity-layer step-up is the smallest application integration when every relevant user already passes through the same identity system. Stick with it when that identity layer can express the action assurance your form needs. It is not suitable when the form must accept a supplier, prospect, or other contact who has no established account, because forcing account enrollment can change the product workflow rather than merely secure it.

How should a NestJS backend combine SMS OTP throttling, audit logs, and recovery codes?

Use one service to enforce challenge transitions and thin controllers to translate transport input. The important unit is not “send an SMS.” It is a record with a purpose, a fixed expiry, an attempt budget, and a consumed state. The code below uses in-memory ports to keep the example readable; production adapters need shared, durable storage and an atomic compare-and-update for consumedAt.

These values are an example policy, not universal defaults: a five-minute lifetime, five guesses per challenge, and three sends in a fifteen-minute window. Pick values from your threat model and support data. More friction is not automatically more security if users learn to route around the control.

import {
  ConflictException,
  Injectable,
  TooManyRequestsException,
  UnauthorizedException,
} from '@nestjs/common';
import { createHmac, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';

type Challenge = {
  id: string;
  accountId: string;
  phone: string;
  intent: string;
  codeDigest: Buffer;
  expiresAt: Date;
  attemptsLeft: number;
  consumedAt?: Date;
};

type AuditEvent = {
  name: 'otp.issued' | 'otp.rejected' | 'otp.verified' | 'recovery.used';
  accountId: string;
  challengeId?: string;
  intent: string;
  reason?: 'expired' | 'mismatch' | 'attempts_exhausted';
  occurredAt: string;
};

interface ChallengeStore {
  insert(challenge: Challenge): Promise<void>;
  find(id: string): Promise<Challenge | undefined>;
  decrementAttempts(id: string): Promise<number>;
  consumeIfActive(id: string, now: Date): Promise<boolean>;
}

interface WindowLimiter {
  take(key: string, limit: number, windowSeconds: number): Promise<boolean>;
}

interface SmsSender {
  sendCode(phone: string, code: string): Promise<void>;
}

interface AuditSink {
  append(event: AuditEvent): Promise<void>;
}

@Injectable()
export class SmsOtpService {
  constructor(
    private readonly challenges: ChallengeStore,
    private readonly limiter: WindowLimiter,
    private readonly sms: SmsSender,
    private readonly audit: AuditSink,
    private readonly otpSecret: string,
  ) {}

  async issue(input: {
    accountId: string;
    phone: string;
    intent: string;
    ip: string;
  }): Promise<{ challengeId: string; expiresAt: string }> {
    const allowed = await Promise.all([
      this.limiter.take(`otp:account:${input.accountId}`, 3, 900),
      this.limiter.take(`otp:phone:${input.phone}`, 3, 900),
      this.limiter.take(`otp:ip:${input.ip}`, 10, 900),
    ]);
    if (allowed.includes(false)) throw new TooManyRequestsException();

    const id = randomUUID();
    const code = randomInt(0, 1_000_000).toString().padStart(6, '0');
    const expiresAt = new Date(Date.now() + 5 * 60_000);
    const codeDigest = this.digest(id, code);

    await this.challenges.insert({
      id,
      accountId: input.accountId,
      phone: input.phone,
      intent: input.intent,
      codeDigest,
      expiresAt,
      attemptsLeft: 5,
    });
    await this.sms.sendCode(input.phone, code);
    await this.audit.append({
      name: 'otp.issued',
      accountId: input.accountId,
      challengeId: id,
      intent: input.intent,
      occurredAt: new Date().toISOString(),
    });

    return { challengeId: id, expiresAt: expiresAt.toISOString() };
  }

  async verify(input: {
    accountId: string;
    challengeId: string;
    code: string;
    intent: string;
  }): Promise<void> {
    const challenge = await this.challenges.find(input.challengeId);
    if (
      !challenge ||
      challenge.accountId !== input.accountId ||
      challenge.intent !== input.intent
    ) {
      throw new UnauthorizedException();
    }
    if (challenge.consumedAt) throw new ConflictException();

    const now = new Date();
    if (challenge.expiresAt <= now) {
      await this.reject(challenge, 'expired');
      throw new UnauthorizedException();
    }

    const attemptsLeft = await this.challenges.decrementAttempts(challenge.id);
    if (attemptsLeft < 0) {
      await this.reject(challenge, 'attempts_exhausted');
      throw new TooManyRequestsException();
    }

    const actual = this.digest(challenge.id, input.code);
    if (!timingSafeEqual(actual, challenge.codeDigest)) {
      await this.reject(challenge, 'mismatch');
      throw new UnauthorizedException();
    }

    const consumed = await this.challenges.consumeIfActive(challenge.id, now);
    if (!consumed) throw new ConflictException();

    await this.audit.append({
      name: 'otp.verified',
      accountId: challenge.accountId,
      challengeId: challenge.id,
      intent: challenge.intent,
      occurredAt: now.toISOString(),
    });
  }

  private digest(challengeId: string, code: string): Buffer {
    return createHmac('sha256', this.otpSecret)
      .update(`${challengeId}:${code}`)
      .digest();
  }

  private async reject(
    challenge: Challenge,
    reason: AuditEvent['reason'],
  ): Promise<void> {
    await this.audit.append({
      name: 'otp.rejected',
      accountId: challenge.accountId,
      challengeId: challenge.id,
      intent: challenge.intent,
      reason,
      occurredAt: new Date().toISOString(),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what is absent from AuditEvent: the code, its digest, the full phone number, and message content. Logs are a second data store. Treat them accordingly.

There is one sharp concurrency edge. Two correct verify requests can arrive almost together because a user double-clicks or a client retries. Reading consumedAt and then writing it in separate operations is insufficient. consumeIfActive must permit exactly one transition from active to consumed; the loser receives a conflict and cannot create a second case. This is where an apparently tidy controller can fail under load even though every line looks reasonable in a single-request test.

Walk through the race with concrete values. Challenge ch_91A has five attempts left and no consumedAt; requests A and B both carry the correct six-digit code for account acct_2048. Each request may read the same active row and independently calculate the same digest. That is fine. The decisive operation comes next: request A updates the row only where consumed_at IS NULL and the expiry remains in the future, then observes one changed row; request B runs the identical conditional update and observes zero changed rows. A may emit otp.verified and continue with submit_support_form:form_7F2. B returns a conflict and emits no success event. If the implementation instead reads, compares, and later performs an unconditional update, both requests can believe they won, both can hand a proof to routing, and the contact form can become two support cases in two queues. The fix belongs in the storage contract, not in a process-local boolean or a controller timing trick, because multiple application instances must agree on the single winner.

One use. One winner.

Bind verification to contact-form routing

Verification should not directly enqueue the support case. Return an authorization fact to the application layer, then ask the routing policy to make the queue decision from trusted server-side data. The client can request security_review; it cannot grant itself that route.

type FormDraft = {
  id: string;
  accountId: string;
  topic: 'billing' | 'security' | 'product';
  subject: string;
};

type VerifiedIntent = {
  accountId: string;
  intent: string;
  verifiedAt: Date;
};

class SupportRoutingService {
  route(draft: FormDraft, proof: VerifiedIntent): string {
    const expectedIntent = `submit_support_form:${draft.id}`;
    if (proof.accountId !== draft.accountId || proof.intent !== expectedIntent) {
      throw new UnauthorizedException();
    }

    if (draft.topic === 'security') return 'security_review';
    if (draft.topic === 'billing') return 'billing_review';
    return 'product_support';
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep the proof short-lived at the application boundary, or consume it as part of the same transaction that creates the case. Otherwise a valid proof may be replayed after someone edits the draft from a product question into a security request. Binding to the immutable draft ID is useful only if edits create a new version or invalidate the old proof.

Test the transition graph, not just controller status codes. A compact suite should cover a correct code, an incorrect code, expiry at the boundary instant, an exhausted attempt budget, a reused challenge, intent mismatch, account mismatch, two concurrent correct submissions, and a delivery retry that does not silently create an extra usable challenge. Then test queue policy separately with fixed VerifiedIntent fixtures. Fast tests stay fast; race tests target the storage adapter that promises atomicity.

One fixture should be painfully specific: account acct_2048, draft form_7F2, intent submit_support_form:form_7F2, and two simultaneous verification calls. Expect one case creation and one conflict. Crisp evidence beats a vague “OTP works” test.

Make recovery codes a separate credential

Recovery codes are not long-lived OTPs. Generate a set once, show the plaintext once, store only keyed digests, and consume one code atomically. A recovery attempt gets its own limiter and its own recovery.used event. It should never pass through the SMS delivery adapter.

Keep the recovery result narrow as well. For a high-impact workflow, successful recovery can restore account access and require a fresh step-up before the contact form reaches a sensitive queue. That adds friction, so document the rule for support staff and expose a clear user state instead of repeatedly asking for a code that can no longer arrive.

The catch is enrollment. Recovery codes help only if the user stored them before losing the phone. They are not suitable as the sole recovery design for accounts where a single employee may leave the customer organization; an organization-level, reviewed recovery process is needed there. SMS also depends on access to the enrolled number, so authenticator-based or identity-layer factors may fit teams that require a factor independent of phone delivery.

Operate the flow from events, not message bodies

Build metrics from low-cardinality event fields: event name, result, intent class, and queue class. Keep account IDs and challenge IDs available for restricted investigation, but do not turn them into metric labels. Useful ratios include issued-to-verified, rejected-by-reason, recovery use, and support cases created after verification. Alert on a change from your own baseline rather than copying a universal threshold that has no relationship to your traffic.

Audit order matters. An otp.issued event means the application accepted and stored a challenge; delivery telemetry describes the next boundary. A verified event means the code matched and the challenge was consumed. A routed event means the case was created. Those are different claims. Combining them into “2FA success” makes it hard to tell whether a user is stuck at delivery, verification, or queue creation.

I'm not sure which ratio should page your team without seeing normal traffic, retry behavior, and support hours. A week of representative events can establish a starting baseline; a load test can validate the concurrent-consumption invariant. Neither requires logging secrets.

Keep the limits visible: SMS OTP is a poor fit when phone access cannot satisfy the assurance policy, application-managed state is a poor fit when the team cannot operate atomic storage and abuse controls, and central identity step-up is a poor fit for legitimate contacts without identities. Choose the boundary first. Then make every transition observable.

References

Top comments (0)