Back to Blog
medium SEVERITY8 min read

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.

O
By Orbis AppSec
Published March 19, 2026Reviewed June 3, 2026

Answer Summary

Rate limit bypass in Express.js occurs when applications trust untrusted X-Forwarded-For headers without proper validation, allowing attackers to rotate IPs and circumvent throttling. The vulnerability is related to CWE-345 (Insufficient Verification of Data Authenticity) and CWE-636 (Not Controlling Generation of Code). The fix involves properly configuring Express's trust proxy setting, validating IP sources, and implementing a whitelist of trusted proxies rather than blindly trusting forwarded headers.

Vulnerability at a Glance

cweCWE-345 (Insufficient Verification of Data Authenticity), CWE-636 (Not Controlling Generation of Code)
fixImplement explicit trust proxy configuration, validate IP sources against a whitelist of trusted proxies, and use secure rate limiting middleware with proper IP identification
riskAttackers can bypass API rate limiting through IP rotation, enabling brute force attacks, credential stuffing, and resource exhaustion
languageJavaScript (Node.js/Express.js)
root causeImproper trust of X-Forwarded-For headers without validating proxy authenticity or implementing strict IP validation
vulnerabilityRate Limit Bypass via IP Header Spoofing

Introduction

Rate limiting is one of the most fundamental security controls in modern web applications. It protects your APIs from abuse, prevents brute force attacks, and ensures fair resource allocation among users. However, as with many security mechanisms, implementation details matter—a lot.

The vulnerability we're discussing today affects an Express.js authentication controller that implemented rate limiting but left the door open for determined attackers to bypass these protections entirely. While the vulnerability was classified as medium severity, the potential impact on authentication endpoints could be severe, enabling credential stuffing attacks, account enumeration, and denial of service.

The Vulnerability Explained

What Went Wrong?

The application used the popular express-rate-limit middleware to throttle requests, but it suffered from two critical weaknesses:

  1. IP Rotation Vulnerability: The rate limiter tracked requests by IP address, but attackers could easily rotate through different IPs using proxy networks, VPNs, or botnets, effectively resetting their rate limit counter with each new IP.

  2. Header Manipulation: The application trusted X-Forwarded-For headers without proper validation. This header is commonly used by reverse proxies to pass the original client IP, but it can be trivially spoofed by attackers.

Technical Deep Dive

When you deploy an Express application behind a reverse proxy (like NGINX or a load balancer), the actual client IP is often lost. The proxy adds headers like X-Forwarded-For to preserve this information:

X-Forwarded-For: 203.0.113.45, 198.51.100.67

A naive rate limiter configuration might look like this:

// VULNERABLE CODE
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  // Trusts X-Forwarded-For without validation
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/api/auth/login', limiter, authController.login);

How Could It Be Exploited?

Scenario 1: Header Spoofing Attack

An attacker can send requests with forged X-Forwarded-For headers:

# Request 1
curl -H "X-Forwarded-For: 192.168.1.1" https://api.example.com/auth/login

# Request 2 (bypasses rate limit)
curl -H "X-Forwarded-For: 192.168.1.2" https://api.example.com/auth/login

# Request 3 (bypasses rate limit)
curl -H "X-Forwarded-For: 192.168.1.3" https://api.example.com/auth/login

Each request appears to come from a different IP, bypassing the rate limiter entirely.

Scenario 2: Distributed Attack via Proxy Networks

Attackers can route requests through:
- Residential proxy networks: Services that route traffic through thousands of legitimate residential IPs
- VPN networks: Rotating through different VPN exit nodes
- Botnets: Using compromised devices distributed across the internet
- Cloud infrastructure: Spinning up multiple cloud instances with different IPs

Real-World Impact

This vulnerability enables several dangerous attack vectors:

  1. Credential Stuffing: Attackers can test millions of username/password combinations from data breaches without being throttled
  2. Account Enumeration: Bypassing rate limits allows attackers to discover valid usernames by testing different accounts
  3. Brute Force Attacks: Password guessing attacks become feasible when rate limits can be circumvented
  4. Resource Exhaustion: Unlimited requests can overwhelm backend services and databases
  5. API Abuse: Scraping, data harvesting, or other automated abuse becomes trivial

Attack Scenario Example

Imagine an e-commerce platform with this vulnerability on its login endpoint:

1. Attacker obtains 10 million email/password pairs from a data breach
2. They use a residential proxy network with 100,000 unique IPs
3. Rate limiter allows 5 login attempts per 15 minutes per IP
4. Attacker can attempt: 100,000 IPs × 5 attempts = 500,000 login attempts every 15 minutes
5. In 24 hours: 48 million login attempts possible
6. Result: Thousands of compromised accounts, potential data breach

The Fix

Implementing Robust Rate Limiting

The proper fix involves multiple layers of defense:

1. Configure Trust Proxy Correctly

// SECURE CODE
const express = require('express');
const app = express();

// Only trust proxy headers from known proxy servers
app.set('trust proxy', ['loopback', 'linklocal', '172.31.0.0/16']);
// Or specify exact proxy IP
// app.set('trust proxy', '172.31.0.1');

2. Use Multiple Rate Limiting Keys

const rateLimit = require('express-rate-limit');

// Combine IP-based and user-based rate limiting
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,

  // Custom key generator for better tracking
  keyGenerator: (req) => {
    // Use the rightmost trusted IP from X-Forwarded-For
    return req.ip;
  },

  // Custom handler for rate limit exceeded
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many login attempts. Please try again later.',
      retryAfter: req.rateLimit.resetTime
    });
  }
});

// Additional user-specific rate limiting
const userLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 10,
  keyGenerator: (req) => {
    // Rate limit by username/email
    return req.body.email || req.body.username || req.ip;
  },
  skipSuccessfulRequests: true // Only count failed attempts
});

app.use('/api/auth/login', loginLimiter, userLimiter, authController.login);

3. Implement Additional Security Layers

const { RateLimiterMemory, RateLimiterRedis } = require('rate-limiter-flexible');

// More sophisticated rate limiting with Redis
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  points: 5, // Number of points
  duration: 900, // Per 15 minutes
  blockDuration: 900, // Block for 15 minutes if exceeded

  // Track by multiple factors
  keyPrefix: 'login_attempt',
});

const rateLimitMiddleware = async (req, res, next) => {
  try {
    // Combine IP and user identifier
    const key = `${req.ip}_${req.body.email || 'anonymous'}`;

    await rateLimiter.consume(key);
    next();
  } catch (rejRes) {
    res.status(429).json({
      error: 'Too many requests',
      retryAfter: Math.ceil(rejRes.msBeforeNext / 1000)
    });
  }
};

4. Add CAPTCHA for Suspicious Activity

const suspiciousActivityDetector = async (req, res, next) => {
  const failedAttempts = await getFailedLoginAttempts(req.ip);

  if (failedAttempts > 3 && !req.body.captchaToken) {
    return res.status(400).json({
      error: 'CAPTCHA verification required',
      requiresCaptcha: true
    });
  }

  if (req.body.captchaToken) {
    const isValid = await verifyCaptcha(req.body.captchaToken);
    if (!isValid) {
      return res.status(400).json({ error: 'Invalid CAPTCHA' });
    }
  }

  next();
};

How This Solves the Problem

The comprehensive fix addresses the vulnerability through:

  1. Proper Proxy Configuration: Only trusts proxy headers from verified sources
  2. Multi-Factor Rate Limiting: Tracks both IP addresses and user identifiers
  3. Distributed Storage: Uses Redis for rate limiting across multiple application instances
  4. Progressive Challenges: Introduces CAPTCHA after detecting suspicious patterns
  5. Granular Control: Different limits for different scenarios (per-IP, per-user, per-endpoint)

Prevention & Best Practices

1. Never Trust User-Controlled Headers Blindly

// BAD: Trusting X-Forwarded-For without validation
const clientIP = req.headers['x-forwarded-for'];

// GOOD: Use Express's trusted proxy configuration
app.set('trust proxy', true); // Only if behind a trusted proxy
const clientIP = req.ip; // Express handles this correctly

2. Implement Defense in Depth

Don't rely on a single security mechanism:

  • Rate limiting (prevents rapid requests)
  • Account lockout (temporarily disables accounts after failed attempts)
  • CAPTCHA (distinguishes humans from bots)
  • MFA (adds additional authentication factor)
  • Behavioral analysis (detects unusual patterns)

3. Use Distributed Rate Limiting

For applications with multiple instances:

// Use Redis or another shared store
const Redis = require('ioredis');
const RedisStore = require('rate-limit-redis');

const limiter = rateLimit({
  store: new RedisStore({
    client: new Redis({
      host: process.env.REDIS_HOST,
      port: process.env.REDIS_PORT,
    }),
  }),
  windowMs: 15 * 60 * 1000,
  max: 5,
});

4. Monitor and Alert

Implement logging and monitoring to detect bypass attempts:

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  handler: (req, res) => {
    // Log potential attack
    logger.warn('Rate limit exceeded', {
      ip: req.ip,
      path: req.path,
      headers: req.headers,
      timestamp: new Date().toISOString()
    });

    // Alert security team if threshold exceeded
    if (shouldAlertSecurityTeam(req)) {
      securityAlert('Potential rate limit bypass attempt detected');
    }

    res.status(429).json({ error: 'Too many requests' });
  }
});

5. Security Standards and References

This vulnerability relates to several security standards:

  • OWASP Top 10 2021: A07:2021 – Identification and Authentication Failures
  • CWE-307: Improper Restriction of Excessive Authentication Attempts
  • CWE-799: Improper Control of Interaction Frequency
  • NIST 800-63B: Digital Identity Guidelines (Authentication and Lifecycle Management)

6. Testing Your Rate Limiter

Always test your rate limiting implementation:

// Example test
describe('Rate Limiter', () => {
  it('should block requests after limit exceeded', async () => {
    // Make 5 requests (at limit)
    for (let i = 0; i < 5; i++) {
      const res = await request(app).post('/api/auth/login');
      expect(res.status).toBe(401); // Unauthorized (wrong credentials)
    }

    // 6th request should be rate limited
    const res = await request(app).post('/api/auth/login');
    expect(res.status).toBe(429);
  });

  it('should not be bypassable with X-Forwarded-For', async () => {
    for (let i = 0; i < 10; i++) {
      const res = await request(app)
        .post('/api/auth/login')
        .set('X-Forwarded-For', `192.168.1.${i}`);

      if (i >= 5) {
        expect(res.status).toBe(429);
      }
    }
  });
});

7. Tools for Detection

  • OWASP ZAP: Test rate limiting effectiveness
  • Burp Suite: Automated rate limit testing
  • Artillery: Load testing to verify limits
  • Custom scripts: Simulate IP rotation attacks

Conclusion

Rate limiting is a critical security control, but its effectiveness depends entirely on proper implementation. As this vulnerability demonstrates, a rate limiter that can be easily bypassed provides a false sense of security—perhaps worse than having no rate limiting at all.

The key takeaways:

  1. Never trust client-provided headers without proper validation and proxy configuration
  2. Implement multiple layers of protection rather than relying on a single mechanism
  3. Use distributed rate limiting for applications running across multiple instances
  4. Monitor and test your rate limiting implementation regularly
  5. Consider the full attack surface, including IP rotation, header manipulation, and distributed attacks

Security is not a checkbox—it's an ongoing process of identifying weaknesses, implementing defenses, and continuously improving. By understanding vulnerabilities like this one and implementing robust fixes, we build more resilient applications that protect both our users and our infrastructure.

Remember: in security, the details matter. A small configuration oversight can undermine an entire security mechanism. Stay vigilant, test thoroughly, and always assume attackers will try to bypass your defenses.

Stay secure, and happy coding!


Have you encountered rate limiting bypass attempts in your applications? Share your experiences and solutions in the comments below.

Frequently Asked Questions

What is rate limit bypass via IP header spoofing?

It's a vulnerability where attackers manipulate HTTP headers (like X-Forwarded-For) to make requests appear to come from different IP addresses, allowing them to circumvent API rate limiters that rely on IP-based throttling.

How do you prevent rate limit bypass in Express.js?

Configure Express's trust proxy setting explicitly, maintain a whitelist of trusted proxies, validate the source of forwarded headers, use rate limiting middleware that properly identifies clients, and implement additional authentication-based rate limiting.

What CWE is rate limit bypass via IP spoofing?

Primarily CWE-345 (Insufficient Verification of Data Authenticity) and CWE-636 (Not Controlling Generation of Code), as the application fails to verify that forwarded IP addresses are legitimate.

Is using X-Forwarded-For enough to prevent rate limit bypass?

No. Using X-Forwarded-For without proper validation actually increases risk. You must validate that the header comes from a trusted proxy and implement fallback mechanisms for identifying clients.

Can static analysis detect rate limit bypass vulnerabilities?

Yes. Static analysis can identify improper trust proxy configurations, direct use of X-Forwarded-For headers without validation, and rate limiting middleware that doesn't validate IP sources.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #72

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versions—including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How Server-Sent Events Injection via Unsanitized Newlines happens in Node.js h3 and how to fix it

A high-severity Server-Sent Events (SSE) injection vulnerability (CVE-2026-33128) was discovered in the h3 HTTP framework, where unsanitized newline characters in event stream fields could allow attackers to inject arbitrary SSE messages. The fix upgrades h3 from version 1.15.5 to 1.15.6 in the frontend's dependency tree, ensuring that newline characters are properly sanitized before being written to event streams.

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How prototype pollution via `__proto__` key happens in Node.js defu and how to fix it

A high-severity prototype pollution vulnerability (CVE-2026-35209) was discovered in the `defu` package version 6.1.4, which allowed attackers to inject properties into JavaScript's `Object.prototype` via the `__proto__` key in defaults arguments. The fix upgrades `defu` to version 6.1.5 in the frontend's dependency tree, protecting downstream consumers like `c12` and `dotenv` configuration loaders from malicious property injection.

critical

How buffer overflow in memcpy() happens in Node.js N-API bindings and how to fix it

A critical buffer overflow vulnerability was discovered in the GetBufferAsVector() function in examples_nodejs/src/zupt_napi.cpp, where memcpy() copied data from JavaScript Uint8Array buffers without proper bounds validation. This vulnerability could allow attackers to trigger memory corruption by providing maliciously crafted input arrays to the native Node.js module, potentially leading to crashes or arbitrary code execution.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.