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, 2026Reviewed 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


Prevention & Best Practices

1. Implement Multi-Layered Rate Limiting

Don't rely on a single rate limit. Combine:

// Per-session resend cap (implemented above)
if (otpDoc.resendCount >= MAX_RESENDS) {
  return res.status(429).json({ ... });
}

// Per-IP rate limiting (use express-rate-limit middleware)
import rateLimit from "express-rate-limit";

const otpLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // Max 10 OTP requests per IP per 15 minutes
  message: "Too many OTP requests from this IP",
});

router.post("/api/auth/send-otp", otpLimiter, sendOtp);

// Per-email rate limiting
const emailOtpLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // Max 5 OTP requests per email per hour
  keyGenerator: (req, res) => req.body.email, // Rate limit by email
  message: "Too many OTP requests for this email",
});

router.post("/api/auth/send-otp", emailOtpLimiter, sendOtp);

2. Use CAPTCHA for OTP Endpoints

Add a CAPTCHA verification step before sending OTP emails:

export const sendOtp = async (req, res) => {
  const { email, captchaToken } = req.body;

  // Verify CAPTCHA with Google reCAPTCHA
  const captchaValid = await verifyCaptcha(captchaToken);
  if (!captchaValid) {
    return res.status(400).json({ success: false, message: "CAPTCHA verification failed" });
  }

  // Proceed with OTP generation
  // ...
};

3. Monitor Email Service Metrics

Track email sending patterns and alert on anomalies:

// Log every OTP send
logger.info("OTP sent", {
  email: email,
  sessionId: sessionId,
  timestamp: new Date(),
  ipAddress: req.ip,
});

// Alert if email sends exceed threshold
if (emailSendCount > DAILY_THRESHOLD) {
  alertSecurityTeam("Possible email exhaustion attack detected");
}

4. Implement Email Verification

Require email verification before allowing OTP requests:

// Only allow OTP for verified emails
const emailDoc = await Email.findOne({ address: email, verified: true });
if (!emailDoc) {
  return res.status(403).json({
    success: false,
    message: "Email must be verified before requesting OTP",
  });
}

5. Use CWE-770 Detection Rules

Leverage static analysis tools to catch this pattern:

Semgrep rule to detect unauthenticated email endpoints:

rules:
  - id: unauthenticated-email-endpoint
    pattern-either:
      - pattern: |
          router.post($PATH, async ($REQ, $RES) => {
            ...
            await sendEmail(...)
          })
      - pattern: |
          @app.route($PATH, methods=['POST'])
          def ...:
            ...
            send_email(...)
    message: "Unauthenticated endpoint triggers email sending—add rate limiting"
    severity: HIGH

6. Reference Standards

  • CWE-770: Allocation of Resources Without Limits or Throttling
  • CWE-799: Improper Control of Interaction Frequency
  • OWASP API3:2019: Excessive Data Exposure
  • OWASP API4:2019: Lack of Resources & Rate Limiting

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.


References

Frequently Asked Questions

What is email quota exhaustion in OTP systems?

It's a DoS attack where attackers repeatedly trigger OTP generation on unauthenticated endpoints, consuming the email service's daily/monthly quota and preventing legitimate users from receiving OTP codes.

How do you prevent email exhaustion in Node.js OTP endpoints?

Combine multiple controls: per-session resend caps (e.g., 3 resends per OTP session), cooldown periods between resends (e.g., 60 seconds), per-IP rate limiting, and per-email rate limiting to throttle requests across different attack vectors.

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.

Is a 60-second cooldown enough to prevent email exhaustion?

No. While the cooldown prevents rapid-fire resends *within a session*, it doesn't prevent an attacker from creating multiple sessions with different emails. A hard cap on resends per session (as implemented here) is essential to limit total email consumption.

Can static analysis detect missing rate limiting?

Yes. Static analysis can flag unauthenticated endpoints that trigger external services (email, SMS, API calls) without rate limiting middleware or throttling logic. Tools like Semgrep can detect patterns like `router.post()` without `rateLimit()` middleware on sensitive endpoints.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #93

Related Articles

high

How IPv4-mapped IPv6 addresses bypass rate limiting in Express.js and how to fix it

A critical vulnerability in express-rate-limit versions prior to 8.2.2 allowed attackers to bypass per-client rate limiting on dual-stack servers by exploiting incorrect IPv6 subnet masking. When IPv4 clients connected through IPv4-mapped IPv6 addresses (like ::ffff:192.0.2.1), the library failed to properly identify unique clients, enabling unlimited requests that could lead to denial of service. The fix upgrades express-rate-limit to 8.2.2 and its dependency ip-address to 10.1.0, implementing

critical

How Missing Rate Limiting Happens in Next.js API Routes and How to Fix It

Three public API endpoints in a Next.js application — `/api/send-review`, `/api/contact`, and `/api/auth` — were deployed without any server-side rate limiting, allowing attackers to flood them with unlimited requests. The `/api/send-review` and `/api/contact` endpoints were especially dangerous because every request triggered an outbound email via Gmail SMTP, making them prime targets for email bombing and quota exhaustion. The fix introduces a lightweight in-memory rate limiter capping each IP

critical

How unlimited batch API calls happen in React JSX and how to fix it

A missing batch size limit in `BatchModeRunner.jsx` allowed users to trigger unlimited LLM API calls by pasting thousands of items into the batch input field. This could exhaust shared API quotas in organizational settings where a single API key is distributed across multiple users. The fix introduces a hard cap of 25 items (`MAX_BATCH_SIZE = 25`) enforced directly in the `canRun()` validation function.

medium

Defending Against Rate Limit Bypass: Securing Express Applications from IP Spoofing

A critical rate limiting vulnerability in an Express.js application allowed attackers to bypass API throttling through IP rotation and header manipulation. This fix demonstrates how improperly configured rate limiters can be circumvented through proxy networks, VPNs, and forged X-Forwarded-For headers, potentially enabling brute force attacks, credential stuffing, and resource exhaustion.

critical

Node-tar Path Traversal: How a Hardlink Bypass Threatened File Systems

A medium-severity vulnerability (CVE-2026-24842) in node-tar allowed attackers to create arbitrary files outside intended directories by exploiting a flaw in hardlink security checks. Combined with missing rate limiting controls, this vulnerability exposed applications to both path traversal attacks and denial-of-service through unlimited automated requests. Here's what happened and how to protect your applications.

high

How Quadratic CPU Consumption Vulnerabilities Happen in JavaScript YAML Parsers and How to Fix Them

A high-severity denial-of-service vulnerability in js-yaml versions 3.x and 4.x allowed attackers to trigger quadratic CPU consumption through specially crafted YAML documents using the !!omap tag. This fix upgrades js-yaml from 4.1.1 to 4.3.1 and from 3.14.2 to 3.15.1, eliminating the algorithmic complexity attack vector that could freeze Node.js applications processing untrusted YAML input.