Cybersecurity
2026-05-28
10 min read

OTP Authentication: The Vulnerabilities Hiding in Your "Secure" Login

Goktug Onyer

Cybersecurity Lead

Authentication code

One-time passwords are everywhere — SMS codes, authenticator apps, email links. They feel secure. But OTP is one of the most commonly mis-implemented security controls we find during code reviews. A login screen with a six-digit box can be wide open and look completely normal.

Here are the coding mistakes that turn OTP into security theater — roughly in the order of how often we see them.

1. No rate limiting — the brute-force gift

A 6-digit numeric OTP has one million possible values. That sounds like a lot until you realise an attacker can try thousands per second against an unprotected endpoint. Without rate limiting, the entire keyspace falls in minutes.

The fix is not optional:

  • Limit attempts per code. Lock the OTP after 3–5 wrong guesses and force a new one to be requested.
  • Limit requests per account and per IP. Otherwise an attacker just keeps requesting fresh codes and guessing one each.
  • Add exponential backoff. Each failed attempt should make the next one slower.

2. Codes that never expire (or expire too slowly)

An OTP valid for an hour is an OTP an attacker has an hour to use. We regularly find codes with no expiry at all, or expiry windows of 15–30 minutes "for user convenience."

30–60 seconds for TOTP, 2–5 minutes for SMS/email codes is the right range. And critically: invalidate the code the instant it's used successfully, and invalidate all outstanding codes when a new one is generated. A surprising number of systems let an old code keep working after a new one is sent.

3. Predictable code generation

This one is brutal when it happens. If you generate OTPs with a non-crypto random source — Math.random() in JavaScript, rand()in C, a timestamp-seeded PRNG — the codes are predictable. An attacker who understands the seed can compute future codes without guessing.

// WRONG — predictable
const otp = Math.floor(Math.random() * 1000000);

// RIGHT — cryptographically secure
import { randomInt } from 'crypto';
const otp = randomInt(0, 1000000).toString().padStart(6, '0');

Always use the platform's cryptographically secure RNG: crypto.randomInt (Node), secrets (Python), SecureRandom (Java/Ruby).

4. Leaking the code in the response or logs

We have genuinely seen production APIs return the OTP in the JSON response to the "send code" call — presumably left over from testing. We've seen codes written to application logs, error-tracking tools, and analytics events. Anywhere the code lands outside the intended channel is a bypass.

  • Never return the OTP in any API response.
  • Never log the OTP, even at debug level.
  • Store only a hash of the OTP server-side, and compare hashes.
  • Scrub OTPs from error reports (Sentry, Datadog, etc.).

5. Non-constant-time comparison

Comparing the submitted code to the stored code with == can leak timing information — a string comparison that bails on the first wrong character returns fractionally faster. With enough samples, an attacker can reconstruct the code character by character. Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest) for anything secret.

6. The big one: OTP doesn't stop modern phishing

Even a perfectly implemented OTP can be defeated by an attacker-in-the-middle phishing kit. Tools like EvilGinx proxy the real login page: the victim enters their password and OTP into the attacker's page, the attacker relays them to the real site in real time, and captures the resulting session cookie. The OTP was valid, used once, within its window — and the attacker still got in.

This is why the security community has moved toward phishing-resistant authentication:

  • Passkeys / FIDO2 / WebAuthn. The credential is bound to the origin (domain), so it simply won't work on a phishing domain. This is the single biggest upgrade you can make.
  • Push-based approval with number matching. Better than plain push (which trains users to tap "approve" reflexively).
  • Hardware security keys for high-value accounts (admins, finance).

SMS OTP specifically has additional problems: SIM-swapping, SS7 interception, and the fact that codes traverse carrier networks you don't control. NIST has deprecated SMS as a primary second factor for years. Use an authenticator app or passkey instead where you can.

A quick implementation checklist

  1. Generate codes with a cryptographically secure RNG.
  2. Store only a hash of the code; compare in constant time.
  3. Short expiry (30–60s TOTP, 2–5min for delivered codes).
  4. Single use — invalidate on success and when a new code is issued.
  5. Rate-limit per code, per account, and per IP, with backoff.
  6. Never expose the code in responses, logs, or error tracking.
  7. Prefer passkeys/WebAuthn over OTP for phishing resistance.
  8. If you must use SMS, treat it as the weakest factor and offer better options.

The bottom line

OTP isn't bad — a well-implemented one-time password is far better than a password alone. But "we have 2FA" is not the same as "we have secure 2FA." The gap between those two is a handful of coding decisions that don't show up in the UI and don't fail any functional test.

If you're not sure where your authentication stands, an auth-focused code review is one of the highest-value security checks you can run. It's usually a day or two of work and frequently surfaces at least one of the issues above.

Related Articles

Secure Coding in the AI Era

Secure Coding in the AI Era

New attack surface, same old discipline — writing secure code with an LLM pair programmer.

Read More
The Vulnerability Classes Defining 2026

The Vulnerability Classes Defining 2026

Identity bypasses, supply chain, prompt injection, SSRF — what attackers actually exploit.

Read More
Business Email Compromise: The Quiet Scam

Business Email Compromise: The Quiet Scam

How a normal-looking email becomes a six-figure wire transfer.

Read More