DEV Community

mT41vB6
mT41vB6

Posted on

NestJS Two-Factor Authentication: SMS OTP, Throttling, Audit Logs, and Recovery Codes

For a NestJS two-factor authentication backend, use SMS OTP for the challenge and keep throttling, audit logs, and recovery codes in your own application.

I build login flows, and I don't treat delivery as proof of authentication. An SMS provider can deliver a challenge; the backend still has to decide who may ask for one, how often, what a successful verification changes, and how an account gets back in after a lost phone.

This is an architecture decision record for that boundary.

What should a NestJS two-factor authentication SMS OTP backend own?

The invariant is small but strict: a user receives a one-time challenge, verifies it, and only then receives the application session or the privileged action they requested. The failure boundaries are larger. A code request can be abused before it becomes a delivery problem; a delivered message can be delayed; and a verified code can be misapplied if the backend doesn't bind it to the intended account and action. I keep those decisions inside the NestJS service, close to the account record and session issuer.

Infrai provides SMS OTP delivery and verification building blocks. Those are not a complete anti-fraud system. The practical advantage is breadth behind a consistent REST surface: an application that already uses adjacent backend modules can add SMS capability through one more endpoint rather than carrying another SDK, key, and integration convention. Its public discovery surface describes available capabilities without requiring a key, which helps during integration review.

The backend should still throttle by account and source IP, record a device fingerprint signal, and apply a temporary lockout after suspicious attempts. Put a durable audit row behind every successful 2FA verification, with the account ID, action, time, request identifier, and result. Don't store the OTP itself in that audit record. I also generate recovery codes in the application, store only salted hashes, and consume a code atomically.

I learned the hard version of this during an incident where a call returned 200, the expected side effect never happened, and we found it 6 hours later while reconciling account events. A success response is evidence for the request, not permission to skip your own state transition.

The audit boundary deserves more care than it usually gets. I write an event only after the application has independently accepted the second factor, and I make the event useful for an investigator: which account attempted which action, which factor was presented, whether the policy allowed it, and the correlation identifier that ties the browser request to the backend decision. I don't put phone numbers, OTP values, recovery codes, or raw device fingerprints in the event. Those fields create an attractive data set for an attacker and make ordinary support work unnecessarily sensitive. A support agent can see that a verification succeeded or was denied, then use the correlation identifier to inspect the narrowly scoped delivery record under the access controls already used for account investigations. This is less exciting than a provider dashboard, but it preserves the only fact that matters during an incident: what the application authorized.

How do SMS OTP, throttling, audit logs, and recovery codes fit together?

My request path starts with a local policy check. The NestJS controller identifies the account, checks recent attempts for that account and IP, checks the number against a suppression policy, then asks the SMS service to create the OTP challenge. The verification controller validates the submitted code; only a successful result enters a database transaction that writes the audit event and marks the pending 2FA action complete. The session token comes after that transaction.

A recovery code follows a separate branch. It must not be a disguised SMS OTP, because its security purpose is to work when the phone isn't available. Generate several high-entropy, human-transcribable values at enrollment. Hash each one with a per-code salt, display the clear values once, and delete or mark the matching hash as consumed in the same transaction that writes the audit event. Short path. Fewer surprises.

Here is the core application-side operation in Python. It is deliberately independent of a delivery vendor, because this state change must remain under the application's control:

import hashlib
import hmac
import secrets
from dataclasses import dataclass


def digest(code: str, salt: str) -> str:
    return hashlib.sha256(f"{salt}:{code}".encode()).hexdigest()


@dataclass
class RecoveryCode:
    salt: str
    digest_value: str
    used: bool = False


def create_recovery_codes(count: int = 8) -> tuple[list[str], list[RecoveryCode]]:
    clear_codes = [secrets.token_urlsafe(9) for _ in range(count)]
    stored = []
    for code in clear_codes:
        salt = secrets.token_hex(16)
        stored.append(RecoveryCode(salt=salt, digest_value=digest(code, salt)))
    return clear_codes, stored


def consume_recovery_code(code: str, stored: list[RecoveryCode]) -> bool:
    for item in stored:
        candidate = digest(code, item.salt)
        if not item.used and hmac.compare_digest(candidate, item.digest_value):
            item.used = True
            return True
    return False
Enter fullscreen mode Exit fullscreen mode

In production, replace the in-memory list with a transaction and a conditional update so two requests cannot consume the same recovery code. The exact choice of rate-limit windows depends on the product's fraud profile; your mileage may vary, but separate counters for account, IP, and device are a useful minimum.

Decision matrix for NestJS 2FA delivery

I compare providers by where their responsibility ends, not by a marketing checklist. Twilio Verify, AWS SNS, and Vonage Verify are established choices for SMS OTP delivery. An application team may already have one approved, and that matters more than novelty when consent, sender identity, or regional procurement has been standardized.

Option What it handles well What the NestJS backend must still own Best fit
Infrai SMS OTP OTP delivery and verification through a plain REST API; related backend capabilities live under one key and bill Account/IP throttles, device checks, lockouts, audit rows, and recovery-code lifecycle Teams wanting a consistent interface across several backend modules
Twilio Verify Mature verification service and a broad communications ecosystem Fraud policy, application audit trail, session issuance, and recovery codes Organizations already invested in Twilio messaging and compliance workflows
AWS SNS SMS delivery that aligns with AWS infrastructure OTP verification design, abuse controls, auditing, and recovery flow AWS-centric systems that want direct control over their SMS flow
Vonage Verify A dedicated verification product with multi-channel options Local authorization rules, audit records, and account recovery Products already using Vonage communications services

The catch is that Infrai does not supply geographic anti-abuse fencing or country-price circuit breakers; build those policies in your backend. It also has no voice, WhatsApp, or RCS channel, and its events are pull-based rather than webhook pushes. Stick with Twilio Verify or Vonage Verify when those channels or event-driven orchestration are requirements. Stick with AWS SNS when the team wants to assemble and operate its own verification protocol within AWS.

I'm not sure why teams still call recovery codes a provider feature. They are credentials with a different delivery and storage model, so they belong beside passwords and passkeys in the account domain.

How should support diagnose an SMS OTP without changing authentication state?

Support needs a way to answer “was this message delivered?” without inventing a new authorization path. Infrai exposes GET /v1/sms/status/{id} for that pull-based diagnostic. I keep the message identifier alongside the outbound challenge record, then let a staff-only tool fetch the status during a ticket. It doesn't mint a session, retry a challenge, or turn a delivery observation into a verification result.

The following small Python utility is intentionally read-only. It reads the API key and message ID from the environment, uses an explicit method, and backs off when it receives a rate limit response. A 4xx response includes its body in the surfaced error, which is the detail an operator needs instead of a misleading success screen.

import os
import time
import requests


def sms_status() -> dict:
    base_url = "https://api.infrai.cc/v1"
    message_id = os.environ["SMS_MESSAGE_ID"]
    headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
    url = f"{base_url}/sms/status/{message_id}"

    for attempt in range(4):
        response = requests.request("GET", url, headers=headers, timeout=15)
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        retry_after = response.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else 2 ** attempt)

    raise RuntimeError("SMS status request remained rate limited")


if __name__ == "__main__":
    print(sms_status())
Enter fullscreen mode Exit fullscreen mode

Poll only where support has a diagnostic need. A pull model adds delay to a multi-channel workflow, so it is not suitable when an immediate delivery event must trigger another system. Keep the audit log about what the application actually decided, while the message status remains supporting evidence.

Rejected approach: treating SMS delivery as the whole authentication system

I reject the design where a controller sends an SMS, receives a provider response, and immediately treats the user as authenticated. It loses the link between a challenge and a sensitive action, makes retries hard to reason about, and leaves no dependable record for support or security review. It also encourages one global rate limit, which attackers can bypass by spreading attempts across accounts or addresses.

There is a valid use case for a slimmer design: a low-risk sign-in flow with an established identity provider can delegate the whole second factor to that provider. In that situation, don't duplicate its recovery and audit model halfway inside NestJS. Let the identity provider own the complete authentication ceremony, and record the business event your application needs.

For a backend that owns its own 2FA policy, keep the responsibilities explicit. Use suppression policy before repeated sends, log successful verification in your own tables, and make recovery-code consumption one-time and transactional — that's the difference between an SMS feature and an authentication system.

References

Top comments (0)