DEV Community

PeterParker8991
PeterParker8991

Posted on

NestJS Login Challenge Design: SMS OTP, Throttling, Audit Trails, and Recovery

Short answer: build SMS two-factor login as a server-owned challenge state machine, with separate budgets for sending and checking, an append-only audit log, and recovery codes that are stored as digests. The backend should return an intentionally boring response, because delivery details and account existence are security signals.

Decision Choose it when Cost you accept
SMS OTP A phone number is already a hard product requirement Carrier delivery, spend, and number-change support
Authenticator app Users can enroll a second device factor Seed backup and device migration become product work
Platform credential Phishing resistance matters more than broad legacy reach Client ceremony and fallback UX take longer to ship

For a one-person SaaS, I pick the option whose failure queue I can operate on a Tuesday night. That usually means a narrow SMS compatibility path, with an authenticator or platform credential offered as the long-term route. Ship weekly. Outsource the undifferentiated delivery plumbing, but keep the security decisions in one application service. It's a boring boundary, and that's the point.

Keep it boring.

How should a NestJS backend handle two-factor SMS OTP throttling and audit logs?

Throttle actions, not just IP addresses. Challenge creation needs one budget; verification attempts need another. Key the policy by account, normalized destination, network bucket, and challenge ID. A public endpoint can acknowledge accepted processing for both known and unknown accounts, while internal events distinguish queued, suppressed, and rejected decisions.

The challenge record is a small state machine: pending, consumed, or expired. Generate the code once, bind its digest to the challenge ID, and set a short expiry. On verification, increment the attempt counter before comparison. A successful compare and consumption must happen atomically, so two concurrent requests cannot mint two sessions from one code. I test that race with two promises released at the same barrier. One winner. Always.

The failure sequence is easy to miss in review. A browser retries after a timeout; the controller sees no response and calls the send path again; the queue receives two jobs; the user enters the first code while the second challenge is now the latest record. With an idempotency key, a server-side resend budget, and a store operation that changes pending to consumed only when the digest and expiry predicate both match, those separate timing events stay separate. The audit trail can then show a suppressed resend or an expired challenge without exposing which phone number or code was involved. Without those boundaries, a harmless network retry turns into duplicate messages, confusing support tickets, and an attacker's cheap way to spend your message budget. I don't let a browser timer carry any of that policy.

Keep the account lockout decision separate from a shared network address. Offices, schools, and carrier gateways make an IP-only rule punish the wrong people. A challenge-specific attempt budget plus an account-level risk decision gives a smaller blast radius. Your mileage may vary; the right thresholds depend on the value of the account and the delivery channel.

The audit event should explain a decision without becoming a credential store. I record an event name, timestamp, subject key, challenge ID, request correlation ID, coarse network signal, outcome, reason code, and policy version. I never record the OTP, a recovery code, a full message body, or a raw authorization header. Stable names such as verification_rejected and challenge_consumed make weekly queries useful.

Recovery codes are a second authentication system

Generate recovery codes once, show them once, and store only keyed digests. Consuming a code and creating a session belong in the same transaction. Regeneration invalidates the old set and emits a recovery_codes_regenerated event. A support operator may start a reviewed identity process, but should not be able to read or invent a code.

Recovery is where the design meets a human. If I can't staff identity review, a manual bypass is not a recovery plan; it is an undocumented factor. I would require two enrolled factors before allowing a user to remove the old one, or make the authenticator path the required fallback. The catch is operational: every extra factor moves work into enrollment, device replacement, and support.

Recovery is product work.

A small TypeScript service boundary

Controllers should translate HTTP input and nothing more. The application service owns policy, state transitions, and event redaction; database and message adapters implement the interfaces.

type VerifyResult =
  | { ok: true; accountId: string }
  | { ok: false; reason: 'invalid_or_expired' };

interface ChallengeStore {
  create(input: {
    id: string;
    accountId: string;
    digest: Buffer;
    expiresAt: Date;
  }): Promise<void>;
  consumeAtomically(input: {
    id: string;
    digest: Buffer;
    now: Date;
  }): Promise<{ consumed: boolean; accountId?: string }>;
}

interface AbusePolicy {
  allowSend(input: {
    accountId: string;
    destinationKey: string;
    networkKey: string;
  }): Promise<boolean>;
}

export class LoginChallengeService {
  constructor(
    private readonly store: ChallengeStore,
    private readonly policy: AbusePolicy,
    private readonly digest: (challengeId: string, code: string) => Buffer,
  ) {}

  async verify(id: string, code: string, now: Date): Promise<VerifyResult> {
    const result = await this.store.consumeAtomically({
      id,
      digest: this.digest(id, code),
      now,
    });
    if (!result.consumed || !result.accountId) {
      return { ok: false, reason: 'invalid_or_expired' };
    }
    return { ok: true, accountId: result.accountId };
  }
}
Enter fullscreen mode Exit fullscreen mode

The queue adapter should use the challenge ID as an idempotency key. A controller retry then asks the queue to perform the same side effect rather than sending another message. The template renderer receives an already-formatted code and expiry label; it doesn't decide whether a send is allowed. Mustache's variables, sections, and inverted sections are enough for that narrow job.

When is SMS OTP the wrong backend example?

SMS is not suitable when a user may lose the number, when delivery uncertainty blocks a time-critical workflow, or when the threat model requires phishing resistance. Stick with an authenticator app when the product can support enrollment and migration. Choose a platform credential when the client experience can handle its ceremony and you want less recurring message operations.

The trade flips with reach and support capacity. An authenticator seed needs protection and a device-transfer story. Platform credentials need cross-device and fallback design. None of these choices removes recovery; each moves the queue of failures. I am not sure any team can pick the “most secure” factor in isolation. Pick the one whose abuse budget, telemetry, and support process you can actually run.

For my revenue-per-hour lens, the useful dashboard is a funnel: requested challenges, policy suppressions, delivered attempts where measurable, successful verifications, recovery use, and factor changes. A spike in requests with flat completions is a signal to investigate. A giant pile of raw request logs is not.

References

Top comments (0)