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
CriticalAn 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:
- Device fingerprinting — flag when an OTP is verified from a new device/IP that doesn't match the user's history
- Number change detection — use carrier APIs to detect recent SIM changes (available through Twilio Lookup)
- Step-up authentication — require additional verification for high-risk actions (password changes, large transactions) even after OTP
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
CriticalSignaling 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:
- Short OTP expiry — reduce the window of vulnerability. Auth1 defaults to 10 minutes; consider 5 minutes for high-security applications.
- IP consistency checks — the OTP request IP and verification IP should match. SS7 intercepts change the device but not the user's session IP.
- Prefer app-based channels — for high-security users, offer TOTP or push notification as alternatives to SMS.
Vulnerability 3: VOIP Number Abuse
VOIP Number Abuse
HighVOIP 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
// 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
// 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 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
HighA 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:
- 4-digit OTP: 10,000 / 20 requests/sec = 500 seconds (8 minutes)
- 6-digit OTP: 1,000,000 / 20 requests/sec = 50,000 seconds (14 hours)
- 6-digit OTP with 10 parallel connections: 1.4 hours
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
// 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 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
MediumStandard 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
// 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
HighIf 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.
// 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' }); }
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.
// 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) |
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.