Back to Blog

Twilio SMS Verification & Alerts in NestJS

Published: June 29, 2026
Twilio SMS Verification & Alerts in NestJS

SMS feels like a small feature until you see the bill. A Twilio SMS verification flow is a handful of lines to send a code — and an unprotected version of it is a public form wired directly to your credit card. Attackers run scripts that hammer open OTP endpoints to pump messages at premium-rate numbers they own, and a quiet weekend can turn into a four-figure telecom invoice before anyone notices.

So the rule up front: the SMS is the easy part; the endpoint protecting it is the feature. Validate numbers to E.164, rate-limit by IP and phone, lock the code after a few wrong guesses, and process delivery webhooks in a queue. Text messages earn their place — famously high open rates make them the right channel for security codes and urgent alerts — but only if the endpoint can't be turned against you. Here's the production version in NestJS.

Twilio SMS verification delivers the code — protecting the endpoint that sends it is the real work

Lock Down the Account First

Before any code: don't share one production credential across environments. Use separate Twilio subaccounts per environment so you can set budget caps, audit message logs cleanly, and revoke a compromised key without touching live traffic. Keep your Account SID, Auth Token, and numbers in encrypted runtime config, never in the repo. This is the boring half of not rolling your own auth — the secret management around the third party matters as much as the code.

Defend Against Toll Fraud

Trusting an incoming phone number without filtering is the most expensive mistake here. Layer the defenses:

  1. Geo-restrict outbound. If you serve North America and Western Europe, block outbound traffic to premium-rate regions in the Twilio console. You can't be pumped toward a country you don't message.
  2. Carrier lookup. Use Twilio Lookup to identify the number type before sending, rejecting unallocated or risky VoIP numbers automatically.
  3. Rate-limit hard. Cap registration attempts per IP and per number with a sliding window in Redis. The mechanics are the same as any API rate limiter — here it's the difference between a capped spend and a runaway one.

The OTP Pipeline (and the Part Everyone Gets Wrong)

Generate a random code, store it in Redis with a TTL and a resend cooldown, and send it:

TypeScript
1// src/notifications/twilio-sms.service.ts
2import { Injectable, BadRequestException } from '@nestjs/common';
3import { InjectRedis } from '@nestjs-modules/ioredis';
4import Redis from 'ioredis';
5import * as crypto from 'crypto';
6
7@Injectable()
8export class TwilioSmsService {
9  private readonly OTP_TTL = 300; // 5 minutes
10
11  constructor(@InjectRedis() private readonly redis: Redis) {}
12
13  async issueOtp(phone: string): Promise<string> {
14    // Cooldown so the resend button can't be spammed.
15    if (await this.redis.get(`cooldown:${phone}`)) {
16      throw new BadRequestException('Please wait before requesting a new code.');
17    }
18
19    const code = crypto.randomInt(100_000, 1_000_000).toString();
20
21    // Store the code + a fresh attempt counter, both expiring together.
22    await this.redis
23      .multi()
24      .set(`otp:${phone}`, code, 'EX', this.OTP_TTL)
25      .set(`otp_attempts:${phone}`, '0', 'EX', this.OTP_TTL)
26      .set(`cooldown:${phone}`, '1', 'EX', 60)
27      .exec();
28
29    return code; // hand off to Twilio to send
30  }
31}

Here's the nuance most tutorials miss: hashing a 6-digit OTP buys you almost nothing. There are only a million possibilities, so a SHA-256 of the code is reversed by brute force instantly. The real protection isn't the hash — it's capping verification attempts. Lock the code after a few wrong guesses:

TypeScript
1async verifyOtp(phone: string, submitted: string): Promise<boolean> {
2  const attempts = Number(await this.redis.incr(`otp_attempts:${phone}`));
3  if (attempts > 5) {
4    await this.redis.del(`otp:${phone}`); // burn it; force a resend
5    throw new BadRequestException('Too many attempts. Request a new code.');
6  }
7  const code = await this.redis.get(`otp:${phone}`);
8  if (!code || code !== submitted) return false;
9
10  await this.redis.del(`otp:${phone}`, `otp_attempts:${phone}`); // single-use
11  return true;
12}

A 5-minute window plus a 5-attempt cap leaves an attacker with a vanishing chance of guessing a million-space code. That's where the security lives.

A six-digit code is only as safe as the attempt limit guarding it

Delivery Webhooks: Ack Fast, Process Async

Twilio returns queued immediately and then POSTs status updates as the message moves through carriers. Don't write to the database inline — Twilio wants a fast 200. Push the payload onto a queue:

TypeScript
1// src/notifications/twilio-webhook.controller.ts
2import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
3import { InjectQueue } from '@nestjs/bullmq';
4import { Queue } from 'bullmq';
5
6@Controller('webhooks/twilio')
7export class TwilioWebhookController {
8  constructor(@InjectQueue('sms-status-queue') private readonly queue: Queue) {}
9
10  @Post('status')
11  @HttpCode(HttpStatus.OK)
12  async status(@Body() body: any) {
13    await this.queue.add('delivery-log', {
14      messageSid: body.MessageSid,
15      status: body.MessageStatus,
16      at: new Date().toISOString(),
17    });
18    return { received: true };
19  }
20}

Same ack-fast pattern as the rest of your integrations — see the job-queue comparison for why the queue, not the handler, does the work.

Normalize Every Number to E.164

Globally, phone inputs are chaos: (555) 123-4567, 03001234567, +1 555 123 4567. Twilio expects strict E.164 — +[country code][number], max 15 digits. Validate and normalize in your DTO with Google's libphonenumber before any send logic runs, so a malformed number fails fast at the edge instead of as a billed delivery error.

Keep the Bill Sane

SMS alerts from a Twilio SMS verification setup are the channel users can't ignore — spend them wisely

SMS is the most expensive channel you'll add, so spend it deliberately:

  • Email-first fallback. Route non-urgent notifications to email; reserve SMS for security codes and genuinely time-critical alerts.
  • Batch closely-timed events. Don't fire five texts for five events in a minute — group them.
  • Know the regional rules. Many countries require pre-registered alphanumeric Sender IDs; unverified numbers get blocked. Check compliance before launching a region, not after.

Twilio SMS Verification: The Security Checklist

LayerVulnerable defaultProduction practice
NumbersRaw text inputE.164 via libphonenumber
OTP secrecyHash the 6-digit codeCap verification attempts + TTL
Toll fraudOpen endpointGeo locks + Lookup + rate limits
WebhooksDB writes inlineAck 200, process in a queue

Build it in this order and SMS becomes the channel users actually notice — a login code that arrives in two seconds, an alert that can't be ignored — instead of the line item that blew up your cloud bill. Send the easy part, but guard the endpoint like it's spending your money, because the moment it's open, it is.

Frequently Asked Questions

Generate a random 6-digit OTP, store it in Redis with a short TTL (around 5 minutes) plus a resend cooldown, and send it via Twilio. The security doesn't come from hashing the code — a 6-digit hash is brute-forceable instantly — it comes from a strict limit on verification attempts: lock the code after a handful of wrong guesses. Validate phone numbers to E.164, and rate-limit the request endpoint to stop toll fraud.

Toll fraud (SMS pumping) is when attackers script your OTP endpoint to blast messages to premium-rate international numbers they control, billing you thousands in hours. Prevent it with layered defenses: geo-restrict outbound traffic to the regions you serve, use Twilio Lookup to reject risky VoIP/unallocated numbers, and apply a sliding-window rate limit per IP and per phone number on the request endpoint.

OTPs are short-lived, high-write, and disposable — exactly what Redis is for. A TTL expires the code automatically with no cleanup job, the in-memory store keeps verification fast, and you avoid loading your primary database with throwaway rows. Store the code and an attempt counter under keys scoped to the phone number, both with the same expiry.

E.164 is the international phone number format: +[country code][number], max 15 digits, e.g. +15551234567. Twilio's API expects it, and storing raw inputs like '(555) 123-4567' will fail outbound delivery. Validate and normalize every number with Google's libphonenumber in your DTO before it reaches any send logic.

Twilio returns 'queued' immediately and then POSTs delivery status updates to your webhook as the message progresses. Don't do database writes inline — Twilio expects a fast 200. Push the status payload onto a background queue and return immediately, then process the delivery log in a worker. Same ack-fast, process-async pattern as any reliable webhook receiver.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio