Course contentsShow
← Node.js
Lesson 996 of 1,9809. Authentication and Identity ManagementFree lesson

Securing One-Time Password Workflows

Build secure email or SMS verification codes that expire and are single-use.

In this lesson, you will learn how to design OTP workflows that prevent reuse and minimize the window of opportunity for attackers.

When implementing One-Time Password (OTP) verification, the biggest risks are "replay attacks" (re-using a code) and "brute-force attacks" (guessing the code). To mitigate these, your backend must enforce strict lifecycle management for every generated token.

Implementing Lifecycle Controls

Instead of storing codes in simple variables, store them in a secure, fast-access key-value store (like Redis) with an automatic Time-to-Live (TTL). Each OTP entry should hold three pieces of data: the hashed_code, the user_id, and a used status flag.

When a user submits a code, follow this sequence:

  1. Hash the input: Never compare raw strings. Use crypto.timingSafeEqual to compare the provided code against your stored hash to prevent side-channel timing attacks.
  2. Verify state: Check if the code is already marked as used. If it is, reject the request and immediately invalidate all pending OTPs for that user to prevent further tampering.
  3. Check expiration: If the TTL has passed, the store will naturally return null. Always treat a missing code as an expired code.
  4. Single-use enforcement: Upon a successful verification, delete the entry or mark it as used in the database immediately before performing any further business logic (like logging the user in).
// Example: Verifying the OTP
async function verifyOtp(userId, providedCode) {
  const storedData = await db.get(`otp:${userId}`);
  
  if (!storedData || storedData.used) {
    throw new Error("Invalid or expired code.");
  }

  const isValid = await bcrypt.compare(providedCode, storedData.hash);
  
  if (!isValid) {
    throw new Error("Incorrect code.");
  }

  // Atomically mark as used to prevent replay
  await db.set(`otp:${userId}`, { ...storedData, used: true }, 'EX', 60);
  return true;
}

Rate Limiting

Beyond single-use logic, you must rate-limit the verification attempts. Even with a short expiration time, an attacker might try to guess a 6-digit code. Limit the number of failed attempts per user (e.g., 3-5 tries) before disabling the OTP feature for that user for a cooling-off period.

Key Takeaway: OTPs must be cryptographically hashed, checked for a "used" status, and backed by a short, strict TTL to guarantee they are truly one-time and ephemeral.