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:
- Attacker calls
/api/auth/send-otpwith emailattacker1@example.com→ OTP is generated and sent - Attacker calls
/api/auth/send-otpwith emailattacker2@example.com→ Another OTP is sent - Attacker calls
/api/auth/send-otpwith emailattacker3@example.com→ Another OTP is sent - 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-otpendpoint 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
- CWE-770: Allocation of Resources Without Limits or Throttling
- CWE-799: Improper Control of Interaction Frequency
- OWASP: Rate Limiting Cheat Sheet
- express-rate-limit npm package
- Semgrep rule: Detecting Unauthenticated Resource-Consuming Endpoints
- fix: the otp endpoints (/api/auth/send-otp, /api/aut... in userRoutes.js