Back to Blog
high SEVERITY10 min read

How Email Exhaustion Denial of Service Happens in Node.js OTP Endpoints and How to Fix It

A Node.js authentication service exposed unauthenticated OTP endpoints without adequate rate limiting, allowing attackers to exhaust email service quotas through repeated requests. The fix implements per-session resend caps and cooldown enforcement to prevent email-based denial of service attacks while preserving legitimate user workflows.

O
By Orbis AppSec
•Published August 14, 2026•Reviewed August 14, 2026

Answer Summary

This vulnerability is a **Denial of Service (DoS) via Resource Exhaustion** in Node.js OTP authentication endpoints. The `/api/auth/send-otp` endpoint lacked rate limiting, allowing attackers to trigger unlimited OTP emails and exhaust service quotas. The fix adds a `MAX_RESENDS` constant (3 resends per session), tracks `resendCount` in the OTP model, and enforces a 429 status code when the limit is exceeded—preventing email budget exhaustion while maintaining legitimate user access.

Vulnerability at a Glance

cweCWE-770 (Allocation of Resources Without Limits or Throttling)
fixImplement per-session resend count tracking with a hard cap (3 resends) and return HTTP 429 when exceeded
riskAttackers can exhaust email service quotas, preventing legitimate OTP delivery and disrupting authentication for all users
languageJavaScript (Node.js)
root causeOTP endpoints lack per-session rate limiting and resend caps, allowing unlimited requests from unauthenticated users
vulnerabilityDenial of Service via Email Quota Exhaustion (Unauthenticated Rate Limiting)

How Email Exhaustion Denial of Service Happens in Node.js OTP Endpoints and How to Fix It

Introduction

In a Node.js authentication backend, we discovered a high-severity Denial of Service vulnerability in the OTP (One-Time Password) endpoints. The issue wasn't a code injection or data breach—it was simpler and more dangerous: unlimited, unauthenticated access to email-triggering endpoints.

The vulnerable code lived in Backend/controllers/OtpController.js and Backend/routes/userRoutes.js. Three endpoints—/api/auth/send-otp, /api/auth/resend-otp, and /api/auth/verify-otp—were publicly accessible without rate limiting. While resend-otp had a per-session 60-second cooldown, the send-otp endpoint had no protection whatsoever. An attacker could call it repeatedly with different email addresses, triggering thousands of OTP emails and exhausting the email service quota—preventing legitimate users from receiving authentication codes.

This is a classic resource exhaustion attack, and it affects any developer using this authentication library in production.


The Vulnerability Explained

What Went Wrong

Let's look at the original OTP model in Backend/models/Otp.js:

const OtpSchema = new mongoose.Schema({
  email: { type: String, required: true },
  otp: { type: String, required: true },
  attempts: { type: Number, default: 0 },
  lastSentAt: { type: Date, default: Date.now },
  expiresAt: { type: Date, default: () => new Date(Date.now() + 5 * 60 * 1000) },
  createdAt: {
    type: Date,
    default: Date.now,
    index: { expireAfterSeconds: 600 },
  },
});

Notice what's missing: there's no field to track how many times an OTP has been resent. The schema only tracks:
- attempts (failed verification attempts)
- lastSentAt (time of last send)
- No resend count

In the resendOtp controller function, the original code looked like this:

export const resendOtp = async (req, res) => {
  const { sessionId } = req.body;

  // Validate input
  if (!sessionId || typeof sessionId !== "string") {
    return res.status(400).json({ success: false, message: "Invalid sessionId" });
  }

  // Fetch OTP document
  const otpDoc = await Otp.findById(sessionId);
  if (!otpDoc) {
    return res.status(404).json({ success: false, message: "OTP session not found" });
  }

  // Check cooldown (60-second minimum between resends)
  if (Date.now() - otpDoc.lastSentAt < RESEND_COOLDOWN_MS) {
    return res.status(429).json({
      success: false,
      message: "Please wait 60 seconds before requesting a new OTP.",
    });
  }

  // Generate a new OTP and overwrite the hash
  const plainOtp = generateOtp();
  otpDoc.setOtp(plainOtp);
  await otpDoc.save();

  await sendOtpEmail(otpDoc.email, plainOtp);
  // ...
};

The Attack Scenario

Here's how an attacker exploits this:

  1. Attacker calls /api/auth/send-otp with email attacker1@example.com → OTP is generated and sent
  2. Attacker calls /api/auth/send-otp with email attacker2@example.com → Another OTP is sent
  3. Attacker calls /api/auth/send-otp with email attacker3@example.com → Another OTP is sent
  4. Attacker repeats with 1,000 different emails in a loop → 1,000 emails sent in minutes

The send-otp endpoint creates a new OTP session for each email address. There's no limit on how many sessions can be created or how many emails can be sent. The attacker never needs to resend the same OTP—they just create new ones.

Even if they did try to exhaust a single session's resends, the 60-second cooldown only prevents rapid-fire resends within that one session. An attacker with patience (or a distributed botnet) can still resend the same OTP every 60 seconds indefinitely.

Real-World Impact

  • Email service quota exhaustion: Most email providers (SendGrid, AWS SES, etc.) have daily/monthly sending limits. An attacker can consume the entire quota in minutes, preventing legitimate users from receiving password resets, OTP codes, or any transactional emails.
  • Authentication disruption: If the email service is throttled or blocked, no legitimate user can complete OTP-based authentication.
  • Reputation damage: If your email IP gets flagged as a spam source, your legitimate emails may be marked as spam.
  • Financial impact: Some email services charge per message. An attacker could rack up significant charges.

The Fix

What Changed

The fix implements a hard cap on resends per OTP session combined with the existing cooldown. Here are the specific changes:

1. Add resendCount field to OTP model (Backend/models/Otp.js)

Before:

const OtpSchema = new mongoose.Schema({
  email: { type: String, required: true },
  otp: { type: String, required: true },
  attempts: { type: Number, default: 0 },
  lastSentAt: { type: Date, default: Date.now },
  expiresAt: { type: Date, default: () => new Date(Date.now() + 5 * 60 * 1000) },
  createdAt: {
    type: Date,
    default: Date.now,
    index: { expireAfterSeconds: 600 },
  },
});

After:

const OtpSchema = new mongoose.Schema({
  email: { type: String, required: true },
  otp: { type: String, required: true },
  attempts: { type: Number, default: 0 },
  lastSentAt: { type: Date, default: Date.now },
  expiresAt: { type: Date, default: () => new Date(Date.now() + 5 * 60 * 1000) },
  createdAt: {
    type: Date,
    default: Date.now,
    index: { expireAfterSeconds: 600 },
  },

  // Hard cap on resends per session to prevent email-budget exhaustion
  resendCount: {
    type: Number,
    default: 0,
  },
});

The resendCount field tracks how many times an OTP has been resent within a single session.

2. Define MAX_RESENDS constant and enforce the cap (Backend/controllers/OtpController.js)

Before:

const MAX_ATTEMPTS = 5;
const RESEND_COOLDOWN_MS = 60 * 1000; // 1 minute between resends

After:

const MAX_ATTEMPTS = 5;
const MAX_RESENDS = 3;  // <-- NEW: Hard cap on resends per session
const RESEND_COOLDOWN_MS = 60 * 1000; // 1 minute between resends

3. Check resend count in resendOtp() function

Before:

export const resendOtp = async (req, res) => {
  const { sessionId } = req.body;

  if (!sessionId || typeof sessionId !== "string") {
    return res.status(400).json({ success: false, message: "Invalid sessionId" });
  }

  const otpDoc = await Otp.findById(sessionId);
  if (!otpDoc) {
    return res.status(404).json({ success: false, message: "OTP session not found" });
  }

  if (Date.now() - otpDoc.lastSentAt < RESEND_COOLDOWN_MS) {
    return res.status(429).json({
      success: false,
      message: "Please wait 60 seconds before requesting a new OTP.",
    });
  }

  const plainOtp = generateOtp();
  otpDoc.setOtp(plainOtp);
  await otpDoc.save();

  await sendOtpEmail(otpDoc.email, plainOtp);
};

After:

export const resendOtp = async (req, res) => {
  const { sessionId } = req.body;

  if (!sessionId || typeof sessionId !== "string") {
    return res.status(400).json({ success: false, message: "Invalid sessionId" });
  }

  const otpDoc = await Otp.findById(sessionId);
  if (!otpDoc) {
    return res.status(404).json({ success: false, message: "OTP session not found" });
  }

  if (Date.now() - otpDoc.lastSentAt < RESEND_COOLDOWN_MS) {
    return res.status(429).json({
      success: false,
      message: "Please wait 60 seconds before requesting a new OTP.",
    });
  }

  // Per-session resend cap — prevents email-budget exhaustion via session replay
  if (otpDoc.resendCount >= MAX_RESENDS) {
    return res.status(429).json({
      success: false,
      message: "Maximum resend limit reached. Please start a new OTP request.",
    });
  }

  const plainOtp = generateOtp();
  otpDoc.setOtp(plainOtp); // also resets attempts and bumps lastSentAt
  otpDoc.resendCount += 1;  // <-- NEW: Increment resend counter
  await otpDoc.save();

  await sendOtpEmail(otpDoc.email, plainOtp);
};

How This Solves the Problem

Before the fix:
- An attacker could resend the same OTP indefinitely (respecting only the 60-second cooldown)
- Over 24 hours, an attacker could resend an OTP ~1,440 times (60-second intervals)
- With multiple concurrent sessions, the attack scales linearly

After the fix:
- Each OTP session can be resent a maximum of 3 times
- After 3 resends, the endpoint returns HTTP 429 (Too Many Requests)
- Users must start a new OTP request (create a new session)
- This limits the email consumption per session while still allowing legitimate users to request new OTPs if they miss the first one

Why 3 resends?
- Initial OTP send: 1 email
- Resend #1: User didn't receive it (maybe spam folder)
- Resend #2: User still didn't receive it (network issue?)
- Resend #3: Final attempt before forcing a new session
- Total: 4 emails per user per session, which is reasonable


Key Takeaways

  • Missing rate limits on OTP endpoints enable email quota exhaustion: The send-otp endpoint had zero protection, allowing attackers to trigger unlimited emails.

  • Per-session resend caps are essential: The fix implements MAX_RESENDS = 3, preventing unlimited resends within a single OTP session while preserving legitimate user workflows.

  • Cooldown periods alone are insufficient: A 60-second cooldown only prevents rapid-fire requests—attackers can still exhaust quotas by waiting or creating multiple sessions.

  • Multi-layered rate limiting is necessary: Combine per-session caps, per-IP limits, per-email limits, and CAPTCHA verification to defend against resource exhaustion attacks.

  • Static analysis can detect this pattern: Tools like Semgrep can flag unauthenticated endpoints that trigger external services (email, SMS, API calls) without rate limiting middleware.


How Orbis AppSec Detected This

Source: HTTP POST requests to /api/auth/send-otp endpoint with user-controlled email parameter—accessible without authentication.

Sink: await sendOtpEmail(otpDoc.email, plainOtp) in Backend/controllers/OtpController.js:107—external email service invocation with no rate limiting check.

Missing control: No per-session resend count tracking, no per-IP rate limiting middleware, and no per-email request throttling on the send-otp endpoint.

CWE: CWE-770 (Allocation of Resources Without Limits or Throttling) and CWE-799 (Improper Control of Interaction Frequency).

Fix: Added resendCount field to OTP schema, defined MAX_RESENDS = 3 constant, and implemented a check in resendOtp() to return HTTP 429 when resend count exceeds the limit. This prevents unlimited email consumption per session while preserving legitimate user access.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

Email quota exhaustion is a subtle but dangerous DoS vulnerability. Unlike SQL injection or XSS, it doesn't compromise data—it disrupts service availability by exploiting the trust between your application and its email provider.

The fix we implemented is simple but effective: track resend attempts per session and enforce a hard cap. Combined with per-IP rate limiting and CAPTCHA verification, this defense-in-depth approach makes the attack economically unfeasible for adversaries.

If you're building authentication systems, OTP endpoints, or any feature that triggers external services (email, SMS, API calls), remember: unauthenticated + external service = rate limit required. Make it a habit to add rate limiting middleware before any endpoint that consumes resources on behalf of the user.

Secure your OTP endpoints today. Your email quota (and your users' authentication experience) will thank you.


Prevention and further reading

Frequently Asked Questions

What CWE covers this vulnerability?

CWE-770 (Allocation of Resources Without Limits or Throttling) describes the core issue—resources (email quota) are consumed without enforced limits. CWE-799 (Improper Control of Interaction Frequency) also applies to the lack of rate limiting.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #93

Related Articles

high

Unbounded Map in createLoginRateLimiter Exhausts API Memory

The console API's `createLoginRateLimiter` and `createMutationRateLimiter` stored one `Map` entry per client key with no upper bound and no expiry sweep, so an attacker rotating source addresses or identifiers could grow those maps until the Node process hit an out-of-memory crash. The fix introduces a `maxTrackedKeys` option (default 5000), a `trackedEntryLimit` sanitizer, and an `evictOldestIfFull` helper that drops the oldest tracked key while protecting the shared global counter. The rate li

high

js-yaml 4.3.1 Denial of Service: Malformed Input Hangs YAML Parser

A denial of service vulnerability in js-yaml versions 4.3.1 and earlier allows attackers to hang the YAML parser indefinitely by providing specially crafted malformed input. The fix, released in versions 4.3.2 and 3.15.2, patches the parsing logic to prevent unbounded processing. Upgrading is recommended for all applications parsing untrusted YAML data.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

critical

Updater.parseUpdate() CWE-494: Unsigned Metadata Download

The parseUpdate function in the Updater component extracted download URLs from remote server responses without cryptographic verification, enabling supply chain attacks via compromised or spoofed update servers. The fix adds strict URL validation requiring HTTPS and a trusted hostname before accepting any update metadata.

high

brace-expansion DoS: Exponential Backtracking in Nested Brace Patterns

A critical vulnerability in brace-expansion allows attackers to cause denial of service by submitting specially crafted patterns with nested braces. The exponential-time complexity in pattern expansion creates a computationally expensive path that can freeze applications processing user-controlled input.

high

CVE-2026-67213: nanoid customAlphabet Infinite Loop Fix

nanoid, a widely-used ID generator pulled in transitively through postcss and vitepress, had an infinite-loop bug in its `customAlphabet` code path before version 5.1.6. This PR pins the entire dependency tree to nanoid 5.1.16 via a pnpm override so no transitive consumer can resolve back to the vulnerable 3.3.16 release.