OTP & SMS Security

OTP Security Best Practices for Developers

Learn how to secure OTP systems with bcrypt hashing, rate limiting, expiry windows, attempt limits, HTTPS enforcement, and idempotency keys.

20 January 202610 min read

StartMessaging Team

Engineering

One-time passwords are the backbone of user verification in modern applications. From login flows to payment confirmations, OTPs protect millions of transactions daily across India. Yet many development teams treat OTP implementation as a simple "generate and send" problem, overlooking critical security considerations that can expose their users to account takeover, financial fraud, and data breaches.

This guide walks through every layer of OTP security that you should implement, from cryptographic hashing to transport-layer enforcement. Whether you are building your own OTP system or integrating a managed OTP API like StartMessaging, these principles apply.

Why OTP Security Matters

OTP codes are short-lived credentials, but they carry significant trust. When a user receives a 6-digit code and enters it correctly, your system grants them access to an account, authorises a payment, or confirms an identity change. If an attacker can intercept, guess, or replay that code, they inherit that trust.

Common attack vectors against OTP systems include:

  • Brute force: With a 6-digit code, there are only 1,000,000 possible combinations. Without attempt limits, an attacker can try all of them in seconds.
  • Database exposure: If OTP codes are stored in plaintext and the database is compromised, every pending OTP is immediately usable.
  • SMS pumping: Attackers trigger thousands of OTP sends to premium-rate numbers, inflating your SMS costs without any real users involved.
  • Replay attacks: A previously valid OTP is resubmitted after it should have been invalidated.
  • Man-in-the-middle: OTP codes intercepted in transit when the API connection is not encrypted.

Each of the practices below addresses one or more of these vectors. Implementing them together creates a defence-in-depth posture that makes your OTP system far harder to compromise.

Hash OTP Codes with bcrypt

The single most important security measure for OTP storage is never storing the OTP code in plaintext. If your database is breached, an attacker with access to raw OTP values can verify any pending request.

Use bcrypt to hash OTP codes before writing them to the database. When a user submits their code for verification, hash the submitted value and compare it against the stored hash.

import bcrypt from 'bcrypt';

// When generating an OTP
const otpCode = generateSecureRandom6Digit();
const saltRounds = 10;
const otpHash = await bcrypt.hash(otpCode, saltRounds);

// Store otpHash in your database — never store otpCode
await db.otpRequests.create({
  phoneNumber: '+919876543210',
  otpHash: otpHash,
  expiresAt: new Date(Date.now() + 5 * 60 * 1000),
  attemptsLeft: 3,
});

// Send the plaintext code to the user via SMS
await sendSms(phoneNumber, `Your verification code is ${otpCode}`);

When verifying:

// User submits their code
const isValid = await bcrypt.compare(submittedCode, storedOtpHash);
if (!isValid) {
  // Decrement attempts remaining
  await db.otpRequests.update(requestId, {
    attemptsLeft: attemptsLeft - 1,
  });
  throw new Error('Invalid OTP code');
}

Why bcrypt instead of SHA-256? bcrypt is deliberately slow, which means even if the hash leaks, an attacker cannot quickly brute-force all 1,000,000 possible 6-digit codes. SHA-256 is fast enough that brute-forcing a 6-digit space takes milliseconds. StartMessaging uses bcrypt hashing for all OTP codes internally, so codes are never stored in a recoverable format.

Enforce Rate Limiting

Rate limiting prevents abuse at the entry point. Without it, attackers can trigger unlimited OTP sends (SMS pumping) or submit unlimited verification attempts (brute force).

Implement rate limits at multiple levels:

  • Per phone number: No more than 3-5 OTP requests per phone number within a 10-minute window. This prevents a single number from being flooded.
  • Per IP address: No more than 10-20 OTP requests per IP within a 10-minute window. This catches attackers rotating through phone numbers from a single source.
  • Global rate limit: Set an upper bound on total OTP sends per minute across your entire application. If this limit is hit, alert your engineering team.

A sliding window approach works well for OTP rate limiting. Rather than resetting counters on fixed intervals, track the timestamp of each request and count how many fall within the trailing window.

// Redis-based sliding window rate limiter
async function checkRateLimit(phoneNumber: string): Promise<boolean> {
  const key = `otp:ratelimit:${phoneNumber}`;
  const windowMs = 10 * 60 * 1000; // 10 minutes
  const maxRequests = 5;
  const now = Date.now();

  // Remove entries outside the window
  await redis.zremrangebyscore(key, 0, now - windowMs);

  // Count remaining entries
  const count = await redis.zcard(key);
  if (count >= maxRequests) {
    return false; // Rate limit exceeded
  }

  // Add the current request
  await redis.zadd(key, now, `${now}`);
  await redis.expire(key, Math.ceil(windowMs / 1000));
  return true;
}

For a deeper dive, read our complete guide to OTP rate limiting. StartMessaging includes built-in per-number and per-IP rate limiting on all OTP endpoints, so you get protection out of the box.

Set Expiry Windows

Every OTP code must have a time-to-live. The longer an OTP remains valid, the larger the window for interception or brute-force attacks.

The recommended expiry window is 5 to 10 minutes. Five minutes is ideal for most login and verification flows. Ten minutes can be appropriate for less time-sensitive operations like email change confirmations where the user might take longer to act.

Best practices for expiry:

  • Store an expiresAt timestamp alongside the hashed OTP. Check it before comparing the hash.
  • Invalidate the OTP immediately after successful verification. Do not allow a valid code to be reused.
  • When a new OTP is generated for the same phone number and purpose, invalidate any previously active OTP for that combination.
  • Run a periodic cleanup job to delete expired OTP records from the database.

For detailed guidance on choosing the right expiry window and handling edge cases, see our OTP Expiry and Attempt Limits Design Guide.

Limit Verification Attempts

Even with bcrypt hashing, you should limit the number of times a user can attempt to verify a given OTP. Three to five attempts is the standard range.

Implementation approach:

  1. Store an attemptsLeft counter (e.g., 3) when the OTP is created.
  2. On each failed verification, decrement the counter.
  3. When the counter reaches zero, mark the OTP as exhausted and require the user to request a new one.
  4. After multiple exhausted OTPs in sequence, apply a cooldown before allowing a new request (e.g., 30 seconds, then 60 seconds, then 5 minutes).
// Verify OTP with attempt limiting
async function verifyOtp(requestId: string, code: string) {
  const otpRequest = await db.otpRequests.findOne(requestId);

  if (!otpRequest || otpRequest.expiresAt < new Date()) {
    throw new Error('OTP expired or not found');
  }

  if (otpRequest.attemptsLeft <= 0) {
    throw new Error('Maximum attempts exceeded. Request a new OTP.');
  }

  const isValid = await bcrypt.compare(code, otpRequest.otpHash);

  if (!isValid) {
    await db.otpRequests.update(requestId, {
      attemptsLeft: otpRequest.attemptsLeft - 1,
    });
    throw new Error(
      `Invalid code. ${otpRequest.attemptsLeft - 1} attempts remaining.`
    );
  }

  // Success — invalidate the OTP
  await db.otpRequests.update(requestId, {
    verifiedAt: new Date(),
    attemptsLeft: 0,
  });

  return { verified: true };
}

Secure Transmission with HTTPS

All communication between your client application, your backend, and any OTP API must use HTTPS (TLS 1.2 or higher). This is non-negotiable.

Key points:

  • API calls: Your backend should only call OTP provider APIs over HTTPS. Reject any provider that offers HTTP-only endpoints.
  • Client to server: Your frontend must submit verification codes over HTTPS. Set up HSTS headers to prevent protocol downgrade attacks.
  • API key transmission: The API key used to authenticate with your OTP provider travels in every request header. Without HTTPS, it is visible to any network observer.
  • Certificate pinning: For mobile apps, consider certificate pinning to prevent man-in-the-middle attacks even on compromised networks.

StartMessaging enforces HTTPS on all API endpoints. Requests to https://api.startmessaging.com/otp/send and https://api.startmessaging.com/otp/verify use TLS 1.2+ exclusively. API keys are transmitted via the X-API-Key header and are stored as SHA-256 hashes on our end.

Use Idempotency Keys

Network failures happen. When a client does not receive a response to an OTP send request, it may retry. Without idempotency protection, each retry generates and sends a new OTP code, confusing the user and wasting SMS credits.

An idempotency key is a unique identifier that the client generates and includes with each request. If the server receives a second request with the same idempotency key, it returns the original response without creating a new OTP.

// Client-side: generate idempotency key before sending
const idempotencyKey = crypto.randomUUID();

const response = await fetch('https://api.startmessaging.com/otp/send', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': 'sm_live_your_api_key_here',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify({
    phoneNumber: '+919876543210',
    expiry: 300,
  }),
});

// If the request times out, retry with the SAME idempotencyKey
// The server will return the original response

On the server side, store the idempotency key with a database unique constraint. When a duplicate arrives, look up the existing record and return its response. StartMessaging supports idempotency keys natively on the /otp/send endpoint, preventing duplicate OTP sends from costing you extra.

Additional Hardening Techniques

Beyond the core practices above, consider these additional layers of protection:

Use Cryptographically Secure Random Generation

Never use Math.random() to generate OTP codes. It is not cryptographically secure and can be predicted. Use crypto.randomInt() in Node.js or equivalent secure random APIs.

import crypto from 'crypto';

function generateOtp(length = 6): string {
  const max = Math.pow(10, length);
  const code = crypto.randomInt(0, max);
  return code.toString().padStart(length, '0');
}

Avoid Leaking OTP Status Information

Your API responses should not reveal whether a phone number exists in your system. Whether the number is registered or not, return the same generic response: "If this number is registered, an OTP has been sent." This prevents phone number enumeration attacks.

Log and Monitor OTP Activity

Track metrics like OTP send volume, failure rates, and verification success rates. Sudden spikes in any of these can indicate an attack. Set up alerts for:

  • More than 100 OTP sends per minute (adjust to your baseline)
  • Verification success rate dropping below 50%
  • A single phone number receiving more than 10 OTPs in an hour
  • A single IP triggering more than 50 OTP requests in an hour

Implement Resend Cooldowns

When users request a new OTP (resend), enforce a cooldown period of at least 30 seconds. This prevents rapid-fire resend abuse and gives the original SMS time to arrive.

How StartMessaging Handles Security

StartMessaging is designed with these security practices built into the platform, so you do not have to implement them from scratch:

Security LayerStartMessaging Implementation
OTP hashingAll codes bcrypt-hashed before storage. Plaintext is never persisted.
Rate limitingPer-phone and per-IP limits applied automatically on send and verify endpoints.
Expiry windowsConfigurable expiry (default 5 minutes). Codes invalidated on successful verify.
Attempt limits3 verification attempts per OTP. Exceeded attempts require a new request.
HTTPSTLS 1.2+ enforced on all endpoints. No HTTP fallback.
IdempotencyNative idempotency key support on /otp/send to prevent duplicate sends.
API key securityKeys are SHA-256 hashed. Full key shown only once at creation time.
Fraud detectionSMS pumping detection with automatic blocking of suspicious patterns.

At Rs 0.25 per OTP, you get enterprise-grade security without the engineering overhead of building these protections yourself.

Security Checklist

Use this checklist when auditing your OTP implementation:

  1. OTP codes are hashed with bcrypt (not stored in plaintext or with fast hashes)
  2. Rate limiting is applied per phone number, per IP, and globally
  3. OTP expiry is set between 5 and 10 minutes
  4. Verification attempts are capped at 3-5 per OTP
  5. All API communication uses HTTPS with TLS 1.2+
  6. Idempotency keys prevent duplicate OTP sends on retries
  7. OTP generation uses cryptographically secure randomness
  8. API responses do not leak phone number registration status
  9. Resend cooldowns of at least 30 seconds are enforced
  10. Monitoring and alerting are set up for anomalous OTP activity
  11. Used OTP codes are invalidated immediately after successful verification
  12. Previous active OTPs are invalidated when a new one is generated

If you want a managed solution that handles all of the above, explore the StartMessaging OTP API. You can go from zero to production-ready OTP verification in under 15 minutes.

Ready to Send OTPs?

Integrate StartMessaging in 5 minutes. No DLT registration required.