Auth1 Blog SMS OTP Security
Security Vulnerabilities · 14 min read

SMS OTP Security:
Why Most Implementations Are Broken

SMS one-time passwords are the most widely deployed second factor in the world. They're also the most commonly misconfigured. SIM swapping, SS7 interception, VOIP abuse, and brute-force attacks exploit weaknesses that exist in the majority of OTP implementations shipping today.

The State of SMS OTP in 2026

SMS OTP remains the default authentication method for billions of users. Banks, SaaS platforms, social networks, and government services all rely on it. And despite repeated calls to move to TOTP or WebAuthn, SMS isn't going anywhere — because it works on every phone, requires no app installation, and users understand it intuitively.

The problem isn't SMS itself. The problem is how developers implement it. Most OTP implementations have at least two of the six critical vulnerabilities described below. Some have all six.


Vulnerability 1: SIM Swapping

SIM Swap Attack

Critical

An attacker calls your mobile carrier, impersonates you using leaked personal information (name, address, last 4 of SSN), and convinces the agent to transfer your phone number to a new SIM. Once the swap completes, the attacker receives all SMS messages sent to your number — including OTP codes.

SIM swapping has been used in high-profile cryptocurrency thefts exceeding $100M. The FCC reported a 400% increase in SIM swap complaints between 2018 and 2023. T-Mobile, AT&T, and Verizon have all faced lawsuits over inadequate SIM swap protections.

What you can do as a developer: You cannot prevent SIM swaps — that's a carrier-side problem. But you can reduce the impact by combining SMS OTP with additional signals:

Auth1's Approach

Auth1 integrates carrier line-type checks on every OTP request. If a number was recently ported or shows signs of a SIM swap, the request is flagged with elevated risk scoring. This data is available in webhook payloads so your application can enforce step-up authentication.


Vulnerability 2: SS7 Interception

SS7 Protocol Attack

Critical

Signaling System 7 (SS7) is the protocol that routes SMS messages between carriers. It was designed in the 1970s with no authentication between network nodes. An attacker with access to an SS7 gateway (available for $1,000-$5,000 on certain markets) can intercept SMS messages in transit without the target's knowledge.

SS7 attacks have been demonstrated by security researchers at Chaos Communication Congress, DEF CON, and in real-world incidents. In 2017, criminals used SS7 interception to drain German bank accounts by intercepting mTAN codes. Government surveillance programs have used SS7 access for years.

Mitigation: Like SIM swaps, you can't fix the protocol. But you can limit exposure:


Vulnerability 3: VOIP Number Abuse

VOIP Number Abuse

High

VOIP numbers from services like Google Voice, TextNow, and Burner can be created in seconds for free. A single person can generate hundreds of VOIP numbers and use each one to create a "verified" account on your platform. This is the primary vector for signup fraud, promo abuse, and fake review farming.

This is the most common attack vector we see at Auth1. It's not sophisticated — there's no exploit involved. The attacker simply uses disposable phone numbers to bypass phone verification. The economics are brutal: one Google Voice number costs $0 and can be created in 30 seconds.

The Bad Implementation

JavaScript bad-otp.js (DO NOT USE)
// This is how most apps verify phone numbers
// Problem: No line-type check. VOIP numbers pass freely.

app.post('/api/verify-phone', async (req, res) => {
  const { phone } = req.body;

  // No line type check - any number is accepted
  // Google Voice, TextNow, Burner all pass through

  const otp = generateOTP();
  await sendSMS(phone, `Your code is: ${otp}`);
  await redis.set(`otp:${phone}`, otp, 'EX', 600);

  res.json({ success: true });
});

The Correct Implementation

JavaScript good-otp.js
// Check line type BEFORE sending OTP
// Blocks VOIP, landline, and toll-free numbers

app.post('/api/verify-phone', async (req, res) => {
  const { phone } = req.body;

  // Step 1: Check line type via Twilio Lookup
  const lookup = await twilioClient.lookups.v2
    .phoneNumbers(phone)
    .fetch({ fields: 'line_type_intelligence' });

  const lineType = lookup.lineTypeIntelligence.type;

  // Step 2: Block non-mobile numbers
  if (['voip', 'landline', 'tollFree'].includes(lineType)) {
    return res.status(400).json({
      error: 'Please use a mobile phone number',
      lineType
    });
  }

  // Step 3: Only send OTP to verified mobile numbers
  const otp = generateSecureOTP();
  await sendSMS(phone, `Your code is: ${otp}`);
  await redis.set(`otp:${phone}`, otp, 'EX', 600);

  res.json({ success: true });
});
Auth1's Approach

Auth1 checks line type on every OTP request automatically. VOIP numbers are blocked by default. This single check eliminates 80-90% of signup fraud for most platforms. The Twilio Lookup costs $0.005 per check — negligible compared to the cost of a fake account.


Vulnerability 4: OTP Brute Forcing

OTP Brute Force Attack

High

A 6-digit OTP has 1,000,000 possible values. Without rate limiting, an attacker can try all combinations in minutes using a simple script. With a 4-digit OTP (still common), there are only 10,000 possibilities — brute-forceable in under 30 seconds.

The Math

A typical API endpoint responds in 50ms. Without rate limiting:

14 hours sounds safe until you realize that attackers use botnets with thousands of IPs. With 100 parallel connections across different IPs, a 6-digit OTP falls in under 10 minutes.

The Fix: Rate Limiting + Attempt Limiting

JavaScript rate-limited-otp.js
// Rate limit OTP requests AND verification attempts

const OTP_REQUEST_LIMIT = 5;     // max 5 OTPs per hour per phone
const OTP_VERIFY_LIMIT = 5;     // max 5 verification attempts per OTP
const OTP_EXPIRY = 600;         // 10 minutes

app.post('/api/auth/request', async (req, res) => {
  const { phone } = req.body;

  // Check request rate limit (per phone number)
  const requestCount = await redis.incr(`otp_req:${phone}`);
  if (requestCount === 1) {
    await redis.expire(`otp_req:${phone}`, 3600); // 1 hour window
  }
  if (requestCount > OTP_REQUEST_LIMIT) {
    return res.status(429).json({
      error: 'Too many OTP requests. Try again in 1 hour.'
    });
  }

  // Generate and store OTP with attempt counter
  const otp = generateSecureOTP();
  await redis.hmset(`otp:${phone}`, {
    code: otp,
    attempts: 0,
    created: Date.now()
  });
  await redis.expire(`otp:${phone}`, OTP_EXPIRY);

  await sendSMS(phone, `Your code: ${otp}`);
  res.json({ success: true });
});

app.post('/api/auth/verify', async (req, res) => {
  const { phone, code } = req.body;

  const otpData = await redis.hgetall(`otp:${phone}`);
  if (!otpData || !otpData.code) {
    return res.status(400).json({ error: 'No active OTP' });
  }

  // Check attempt limit
  const attempts = parseInt(otpData.attempts) + 1;
  if (attempts > OTP_VERIFY_LIMIT) {
    await redis.del(`otp:${phone}`); // Invalidate OTP
    return res.status(429).json({
      error: 'Too many attempts. Request a new code.'
    });
  }
  await redis.hset(`otp:${phone}`, 'attempts', attempts);

  // Constant-time comparison to prevent timing attacks
  const valid = crypto.timingSafeEqual(
    Buffer.from(code),
    Buffer.from(otpData.code)
  );

  if (!valid) {
    return res.status(401).json({ error: 'Invalid code' });
  }

  // Success - delete OTP (prevent replay)
  await redis.del(`otp:${phone}`);
  return res.json({ verified: true });
});
Auth1's Defaults

Auth1 enforces 5 OTP requests per hour per phone number, 5 verification attempts per OTP, and 10-minute expiry. These limits are configurable per tenant but cannot be raised above safe maximums (10 requests/hour, 10 attempts, 30-minute expiry).


Vulnerability 5: Timing Attacks

Timing Side-Channel

Medium

Standard string comparison (===) in JavaScript, Python, and most languages returns false on the first mismatched character. This means comparing "000000" to "482913" takes less time than comparing "482910" to "482913". An attacker can measure response times to deduce the OTP one digit at a time, reducing the search space from 1,000,000 to ~60 attempts.

Why This Matters

In a controlled network environment (same data center, low jitter), timing differences as small as 100 nanoseconds can be measured statistically over multiple samples. An attacker sending 1,000 requests for each digit position can reliably extract the OTP in under a minute.

The Fix: Constant-Time Comparison

JavaScript timing-safe.js
// BAD: Variable-time string comparison
if (userInput === storedOTP) { ... }

// GOOD: Constant-time comparison
const crypto = require('crypto');

function verifyOTP(userInput, storedOTP) {
  // Pad to same length to prevent length-based timing leak
  const a = Buffer.from(userInput.padEnd(6, '0'));
  const b = Buffer.from(storedOTP.padEnd(6, '0'));

  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}

In Python, use hmac.compare_digest(). In Go, use subtle.ConstantTimeCompare(). In Rust, use the constant_time_eq crate. Never use == or === for OTP comparison.


Vulnerability 6: Replay Attacks

OTP Replay Attack

High

If an OTP is not invalidated immediately after successful verification, it can be used again. An attacker who intercepts the OTP (via shoulder surfing, screen recording, or log exposure) can replay it within the expiry window to gain access.

This is embarrassingly common. Many implementations store the OTP with a TTL but don't delete it on successful verification. The code is valid for the full expiry period, even after it's been used.

JavaScript replay-prevention.js
// BAD: OTP remains valid after use
if (storedOTP === userInput) {
  res.json({ verified: true });
  // OTP still in Redis for 10 more minutes!
  // Attacker can use the same code again
}

// GOOD: Delete OTP atomically on verification
const storedOTP = await redis.getdel(`otp:${phone}`);
// GETDEL is atomic: reads and deletes in one operation
// The OTP can never be used twice

if (storedOTP && timingSafeEqual(userInput, storedOTP)) {
  res.json({ verified: true });
} else {
  res.status(401).json({ error: 'Invalid or expired code' });
}
The Atomic Requirement

It's not enough to read the OTP, compare it, and then delete it in separate steps. A race condition between the read and delete allows a second request to use the same code. Use Redis GETDEL (Redis 6.2+) or a Lua script that performs the read-compare-delete atomically.


Secure OTP Generation

The OTP itself must be generated using a cryptographically secure random number generator. Math.random() is not cryptographically secure — its output is predictable if the internal state is known.

JavaScript secure-otp-generation.js
// BAD: Predictable PRNG
function generateOTP() {
  return Math.floor(Math.random() * 1000000)
    .toString()
    .padStart(6, '0');
}

// GOOD: Cryptographically secure random
const crypto = require('crypto');

function generateSecureOTP(length = 6) {
  // Generate random bytes and convert to bounded integer
  const max = Math.pow(10, length);
  const randomBytes = crypto.randomBytes(4);
  const randomInt = randomBytes.readUInt32BE(0) % max;
  return randomInt.toString().padStart(length, '0');
}

// In Node.js 14+, even cleaner:
function generateSecureOTP(length = 6) {
  const max = Math.pow(10, length);
  return crypto.randomInt(0, max)
    .toString()
    .padStart(length, '0');
}

How Auth1 Handles Every Vulnerability

Auth1 was built with these vulnerabilities in mind from day one. Here's a summary of how each attack vector is addressed:

Vulnerability Auth1's Defense Configurable?
SIM Swapping Carrier line-type checks, risk scoring, webhook alerts Risk threshold per tenant
SS7 Interception 10-minute OTP expiry, IP consistency checking Expiry: 5-30 min
VOIP Abuse Automatic VOIP blocking on every request Toggle per tenant
Brute Forcing 5 requests/hr, 5 attempts/OTP, 6-digit minimum Within safe limits
Timing Attacks crypto.timingSafeEqual for all comparisons No (always on)
Replay Attacks Atomic GETDEL on verification, one-time use No (always on)
The Bottom Line

Every line of defense above is implemented automatically when you use Auth1's /api/auth/request and /api/auth/verify endpoints. You don't need to build rate limiting, VOIP checking, or constant-time comparison yourself. One API call handles all six attack vectors.

Stop Building Broken OTP

Auth1 handles rate limiting, VOIP blocking, constant-time comparison, and replay prevention automatically. One API call. Every vulnerability covered.

Start Free → Read the Docs
Free tier · 1,000 verifications/month · No credit card required