NestJS two-factor auth over SMS: throttles, recovery codes, audit rows
The OTP is two HTTP calls. The 2FA feature is enrollment state, a throttle guard, single-use recovery codes and an audit trail — wired as NestJS providers.
Sending and checking the passcode is the small part: POST /v1/sms/otp issues it, POST /v1/sms/verify checks it, and Infrai keeps the code, its TTL and the attempt counter server-side so none of that lands in your Redis. What’s left is the actual feature — an enrollment state machine, two different throttles, recovery codes that survive a lost phone, and an audit trail somebody will ask you for during a security review.
NestJS is a good fit for that remainder because each piece is a provider you can test in isolation. This is a working TwoFactorModule: the gateway client, the throttle guard, the recovery-code service and the audit writer, in TypeScript, with the parts that people usually get subtly wrong called out as they appear.
Division of labour
| Concern | Owner | Why |
|---|---|---|
| Code generation, TTL, attempt cap | Infrai sms.otp / sms.verify | You never store a passcode, so you can’t leak one |
| Per-IP request rate | @nestjs/throttler | Cheapest place to stop a script |
| Per-user resend cooldown | Your provider | Business rule, and it’s what caps the bill |
| Recovery codes | Your database | Nobody else can hash them for you |
| Audit rows | Your database | Reviewers want your trail, not a vendor’s |
| Sender identity | Infrai sms.signature.create | Registration is per-account, not per-app |
The dividing line is worth stating plainly: the gateway owns secrets you don’t want, and you own policy it can’t know.
Module wiring
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { TwoFactorController } from "./two-factor.controller";
import { SmsGateway } from "./sms.gateway";
import { RecoveryCodeService } from "./recovery-code.service";
import { AuditService } from "./audit.service";
@Module({
imports: [
ThrottlerModule.forRoot([
{ name: "short", ttl: 60_000, limit: 5 },
{ name: "long", ttl: 3_600_000, limit: 20 },
]),
],
controllers: [TwoFactorController],
providers: [
SmsGateway,
RecoveryCodeService,
AuditService,
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class TwoFactorModule {}
Five requests a minute per IP stops the obvious script. It does nothing about one determined caller cycling residential proxies against a single phone number, which is why the per-user cooldown lives one layer down.
The gateway provider
import { Injectable, Logger } from "@nestjs/common";
interface ApiError extends Error {
status: number;
code?: string;
permanent: boolean;
}
@Injectable()
export class SmsGateway {
private readonly base = "https://api.infrai.cc";
private readonly key = process.env.INFRAI_API_KEY;
private readonly log = new Logger(SmsGateway.name);
constructor() {
if (!this.key) throw new Error("INFRAI_API_KEY is unset (use your_infrai_api_key locally)");
}
private headers(): Record<string, string> {
return { Authorization: `Bearer ${this.key}`, "Content-Type": "application/json" };
}
private async unwrap(res: Response): Promise<any> {
const json = await res.json().catch(() => ({}));
if (res.ok) return json.data;
const message: string = json?.error?.message ?? `HTTP ${res.status}`;
const err = new Error(message) as ApiError;
err.status = res.status;
err.code = json?.error?.code;
err.permanent = /e\.164|invalid|not supported|malformed/i.test(message);
throw err;
}
async isSuppressed(phone: string): Promise<boolean> {
const res = await fetch(`${this.base}/v1/sms/suppression/check`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ phone }),
});
const data = await this.unwrap(res);
return Boolean(data.suppressed);
}
async challenge(phone: string): Promise<string> {
const res = await fetch(`${this.base}/v1/sms/otp`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ to: phone, template: "{code} is your Acme verification code." }),
});
const data = await this.unwrap(res);
this.log.debug(`challenge issued request_id=${data.request_id}`);
return data.request_id as string;
}
async check(phone: string, code: string): Promise<boolean> {
const res = await fetch(`${this.base}/v1/sms/verify`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ to: phone, code }),
});
const data = await this.unwrap(res);
return Boolean(data.verified);
}
}
challenge() returns a request_id, and there’s a caveat attached to it: it isn’t a message id. Delivery tracking runs off the message_id that POST /v1/sms/send returns, so a managed OTP has no documented way to poll whether the text arrived. You find out when the user either types a code or doesn’t. If your support team needs “did the SMS reach them” as a first-class answer, send your own message with sms.send and do the code comparison yourself — that’s the trade for not storing passcodes.
The controller, with both throttles
import { Body, Controller, Ip, Post, UnauthorizedException } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { SmsGateway } from "./sms.gateway";
import { RecoveryCodeService } from "./recovery-code.service";
import { AuditService } from "./audit.service";
const COOLDOWN_MS = 45_000;
const cooldowns = new Map<string, number>();
@Controller("2fa")
export class TwoFactorController {
constructor(
private readonly sms: SmsGateway,
private readonly recovery: RecoveryCodeService,
private readonly audit: AuditService,
) {}
@Post("challenge")
@Throttle({ short: { ttl: 60_000, limit: 3 } })
async challenge(@Body() dto: { userId: string; phone: string }, @Ip() ip: string) {
const until = cooldowns.get(dto.userId) ?? 0;
if (Date.now() < until) {
await this.audit.write(dto.userId, "2fa.challenge.cooldown", ip, {});
return { sent: false, retryInMs: until - Date.now() };
}
if (await this.sms.isSuppressed(dto.phone)) {
await this.audit.write(dto.userId, "2fa.challenge.suppressed", ip, { phone: dto.phone });
return { sent: false, reason: "number opted out of messages" };
}
const requestId = await this.sms.challenge(dto.phone);
cooldowns.set(dto.userId, Date.now() + COOLDOWN_MS);
await this.audit.write(dto.userId, "2fa.challenge.sent", ip, { requestId });
return { sent: true, retryInMs: COOLDOWN_MS };
}
@Post("verify")
@Throttle({ short: { ttl: 60_000, limit: 5 } })
async verify(@Body() dto: { userId: string; phone: string; code: string }, @Ip() ip: string) {
const viaRecovery = await this.recovery.burn(dto.userId, dto.code);
if (viaRecovery) {
await this.audit.write(dto.userId, "2fa.verify.recovery_code", ip, {});
return { verified: true, factor: "recovery_code" };
}
const ok = await this.sms.check(dto.phone, dto.code);
await this.audit.write(dto.userId, ok ? "2fa.verify.ok" : "2fa.verify.fail", ip, {});
if (!ok) throw new UnauthorizedException("code rejected");
cooldowns.delete(dto.userId);
return { verified: true, factor: "sms" };
}
}
Recovery codes are tried first, and deliberately so — otherwise a user holding a backup code pays for an SMS verify call every time they use one. Swap the in-memory cooldowns map for Redis before you run a second instance; everything else in the controller is stateless.
Recovery codes that are actually recoverable
import { Injectable } from "@nestjs/common";
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(scryptCb) as (p: string, s: string, k: number) => Promise<Buffer>;
const ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
@Injectable()
export class RecoveryCodeService {
constructor(private readonly db: { query: (t: string, p: unknown[]) => Promise<{ rows: any[] }> }) {}
private format(): string {
const bytes = randomBytes(10);
const chars = [...bytes].map((b) => ALPHABET[b % ALPHABET.length]).join("");
return `${chars.slice(0, 5)}-${chars.slice(5, 10)}`;
}
async issue(userId: string): Promise<string[]> {
const plain = Array.from({ length: 10 }, () => this.format());
await this.db.query("DELETE FROM recovery_code WHERE user_id = $1", [userId]);
for (const code of plain) {
const hash = await scrypt(code, userId, 32);
await this.db.query(
"INSERT INTO recovery_code (user_id, hash, used_at) VALUES ($1, $2, NULL)",
[userId, hash.toString("hex")],
);
}
return plain; // shown once, never retrievable again
}
async burn(userId: string, candidate: string): Promise<boolean> {
const normalised = candidate.trim().toUpperCase();
if (!/^[A-Z0-9]{5}-[A-Z0-9]{5}$/.test(normalised)) return false;
const hash = (await scrypt(normalised, userId, 32)).toString("hex");
const { rows } = await this.db.query(
"SELECT id, hash FROM recovery_code WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const target = Buffer.from(hash, "hex");
for (const row of rows) {
const stored = Buffer.from(row.hash, "hex");
if (stored.length === target.length && timingSafeEqual(stored, target)) {
await this.db.query("UPDATE recovery_code SET used_at = now() WHERE id = $1", [row.id]);
return true;
}
}
return false;
}
}
Ten codes, single use, salted per user, compared in constant time. The alphabet drops I, L, O, U and 0/1 so a code read off paper over the phone doesn’t turn into a support ticket.
The audit table a reviewer will ask for
CREATE TABLE audit_2fa (
id bigserial PRIMARY KEY,
user_id text NOT NULL,
event text NOT NULL, -- 2fa.challenge.sent, 2fa.verify.fail, 2fa.recovery.issued
ip inet,
meta jsonb NOT NULL DEFAULT '{}',
at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON audit_2fa (user_id, at DESC);
CREATE INDEX ON audit_2fa (event, at DESC) WHERE event LIKE '2fa.verify.%';
Write a row on every branch, including the ones that didn’t send anything. A cooldown rejection with no audit row looks identical to a request that never arrived, and the difference is what you’ll be asked about.
The 503 that isn’t a server problem
{
"ok": false,
"error": {
"code": "VENDOR_DOWN",
"http_status": 503,
"message": "recipient not in E.164 format: '(415) 555-0142'",
"retryable": true
}
}
Malformed input reaches you through the vendor channel with retryable: true set, so a NestJS HTTP interceptor that retries every 5xx will burn its attempts on a number that can never work. That’s why unwrap() derives permanent from the message text before anything upstream decides to try again.
What 2FA costs per enrolled user
Verified 2026-07-26: an OTP message runs about $0.0075 and a verify call $0.005, so a clean enrollment — one text, one correct code — is roughly $0.0125, and a user who fumbles twice costs about $0.023. Recovery-code logins cost nothing because they never touch the gateway. Rates drift downward and campaigns run, so read your own:
curl -s https://api.infrai.cc/v1/account/usage \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"period": "30d",
"total_cost": 12.03,
"breakdown": [
{ "key": "sms.otp", "cost": 0.2242, "calls": 30, "failed_calls": 0 },
{ "key": "sms.verify", "cost": 0.015, "calls": 3, "failed_calls": 0 }
]
}
}
A verify-to-otp call ratio far below 1 means codes aren’t arriving; far above 1 means users are mistyping, or somebody is guessing.
Where this stops
There’s a real limitation on the managed flow: no delivery status for OTP messages, no Retry-After or X-RateLimit-* headers on any route, and SMS_RATE_LIMIT when you cross the platform ceiling — so your own throttle numbers are also your backoff policy. Twilio Verify exposes per-service rate-limit buckets and channel fallback to voice as configuration rather than code, and Vonage runs the whole resend schedule inside its verification workflow; if phone verification is the only integration you’re doing and you want those knobs in a dashboard, stick with a specialist. NestJS users with Passport already in place may also find an identity provider such as a hosted auth service does 2FA as a checkbox.
The case for wiring it this way is that the same key sends the “new device signed in” email, queues the audit export, and reports the spend per tenant. One credential, one bill, and the only bespoke code is the module above.