Rate limit by IP in Express and Nginx (without limiting your own proxy)

Per-IP rate limits fail in two opposite ways behind a proxy: every user shares the proxy's bucket, or every attacker gets a fresh one by forging X-Forwarded-For. Here is a setup for Express and Nginx that avoids both, tested against real requests.

On this page Step 0: get the client IP right

Rate limiting by IP is the first defence against credential stuffing, scraping and runaway clients. It's also easy to get wrong in a way that looks fine in testing:

  1. Everyone shares one bucket. Behind Nginx, a load balancer or a CDN, the app sees the proxy's address. After a handful of requests from anyone, everyone gets 429 Too Many Requests.
  2. Attackers get unlimited buckets. The "fix" for problem 1 is often to key on X-Forwarded-For's first entry, or to set Express's trust proxy to true. Both let a client send a different made-up IP with every request and never hit the limit.
  3. IPv6 users get 2^64 buckets. One IPv6 customer typically has at least a /64, which is 18 quintillion addresses. Limiting per full address limits nothing.

Below is a setup for Express (with express-rate-limit v8) and for Nginx (limit_req) that avoids all three. We ran every config here against real requests and show the results.

Step 0: get the client IP right

A rate limiter is only as good as the address it counts. Before touching limits:

Express: express-rate-limit v8

express-rate-limit is the standard middleware. A few things changed in recent major versions that older tutorials miss:

  • max was renamed to limit in v7 (max still works).
  • The default key is the client IP passed through ipKeyGenerator, which groups IPv6 addresses by subnet. The ipv6Subnet option defaults to 56 (a /56).
  • It validates your setup at runtime and logs errors such as ERR_ERL_PERMISSIVE_TRUST_PROXY (you set trust proxy to true) and ERR_ERL_UNEXPECTED_X_FORWARDED_FOR (an X-Forwarded-For header arrived but trust proxy is off). Read your logs after deploying: these two catch problems 1 and 2 above.
js
import express from 'express';
import { rateLimit, ipKeyGenerator } from 'express-rate-limit';

const app = express();
app.set('trust proxy', 'loopback'); // Nginx on the same host; see Step 0
app.use(express.json());

// General API limit: 100 requests per minute per client
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-8', // RateLimit / RateLimit-Policy headers
  legacyHeaders: false,       // no X-RateLimit-* headers
});

// Login: 10 attempts per 15 minutes per IP *and* account
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  keyGenerator: (req) => `${ipKeyGenerator(req.ip)}|${String(req.body?.email ?? '').toLowerCase()}`,
  message: { error: 'Too many login attempts. Try again later.' },
});

app.use('/api', apiLimiter);
app.post('/login', loginLimiter, (req, res) => {
  // ... check credentials ...
  res.json({ ok: true });
});

app.listen(3000, '127.0.0.1');

This is what it did with Express 5.3 and express-rate-limit 8.7, behind a proxy on loopback, with the limit lowered to 3 for the test:

RequestsResult
4 from 198.51.100.9200, 200, 200, 429. The 429 carries Retry-After: 60
then 1 from 198.51.100.10200: a separate bucket
4 from different /64s inside 2001:db8:1::/56200, 200, 200, 429: one bucket for the /56
X-Forwarded-For: 9.9.9.9, 198.51.100.9 (forged first entry)429: still counted as 198.51.100.9

The response headers with draft-8 look like RateLimit: "3-in-1min"; r=0; t=60 and RateLimit-Policy: "3-in-1min"; q=3; w=60; …. Well-behaved clients can slow down before they hit the wall.

Custom keys: always wrap req.ip

If you write a keyGenerator that uses the IP, pass it through ipKeyGenerator(req.ip), as in the login limiter. Returning req.ip directly gives every IPv6 address its own bucket, and the library warns with ERR_ERL_KEY_GEN_IPV6. Want a different IPv6 grouping? Set ipv6Subnet (the docs suggest values between 32 and 64) or pass it as the second argument: ipKeyGenerator(req.ip, 64).

Keying login attempts on IP and account means a shared office IP doesn't lock out everyone at once, while one attacker still can't hammer one account. Add a separate per-account limit (key on the email alone) if you also worry about distributed guessing against one user.

Skipping trusted callers

js
import { BlockList } from 'node:net';

const noLimit = new BlockList();
noLimit.addSubnet('192.0.2.0', 24); // office, uptime monitor...

const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  skip: (req) => noLimit.check(req.ip),
});

Skip by address, never by a header the client can set ("X-Internal: 1").

More than one process or server

The default MemoryStore lives inside one Node.js process. It resets on restart, and each process counts separately, so four cluster workers allow four times the limit. With more than one process, use one of the shared stores listed in the express-rate-limit docs (Redis, Memcached and others) so all instances count together.

Nginx: limit_req

Limiting in Nginx stops abusive traffic before it costs your application anything. The limit_req module implements a leaky bucket per key. The full http {} part:

nginx
http {
    # 1. Restore the client address. Trust only your load balancer / CDN.
    set_real_ip_from 10.0.0.0/16;          # your load balancer subnet
    real_ip_header   X-Forwarded-For;
    real_ip_recursive on;

    # 2. Addresses that are never limited
    geo $rate_limit_exempt {
        default        0;
        192.0.2.0/24   1;   # e.g. office / monitoring
    }
    map $rate_limit_exempt $limit_key {
        0  $binary_remote_addr;
        1  "";              # empty key = not counted
    }

    # 3. Zones: 10 MB is roughly 80,000 addresses on 64-bit systems
    limit_req_zone $limit_key zone=per_ip:10m rate=10r/s;
    limit_req_zone $limit_key zone=login:10m  rate=5r/m;
    limit_req_status 429;
    limit_req_log_level warn;

    server {
        # ... listen, server_name, TLS ...

        location / {
            limit_req zone=per_ip burst=20 nodelay;
            proxy_pass http://127.0.0.1:3000;
        }

        location = /login {
            # limit_req is only inherited when a level has none, so list both here
            limit_req zone=login  burst=5 nodelay;
            limit_req zone=per_ip burst=20 nodelay;
            proxy_pass http://127.0.0.1:3000;
        }
    }
}

We ran this with Nginx 1.24, with set_real_ip_from pointed at the test client, firing requests concurrently:

TrafficResult
40 simultaneous requests from one client21 × 200 (1 + burst=20), 19 × 429
10 more from the same client, each with a different forged first X-Forwarded-For entry10 × 429: the forgery doesn't create new buckets
5 from another client5 × 200
40 from the exempt range40 × 200
8 sequential /login from one client6 × 200, then 429

What each piece does

  • $binary_remote_addr is the client address in 4 or 16 bytes. Use it rather than $remote_addr to save memory. After realip it's the client's address, not the load balancer's.
  • Zone size. From the docs: one megabyte holds about 16.000 states on 32-bit platforms or 8.000 on 64-bit. Size the zone for the number of distinct addresses you expect in a window. When it's full, Nginx evicts the least recently used state.
  • rate + burst + nodelay. rate=10r/s is the sustained rate. burst=20 lets a client go 20 requests over it, for page loads with many assets or API clients that batch. nodelay serves those burst requests immediately instead of spacing them out. Without nodelay, excess requests are delayed to fit the rate, which ties up connections.
  • limit_req_status 429. The default is 503, which tells clients and monitors that your server is broken. 429 tells them to slow down.
  • Empty key = not limited. Nginx doesn't count requests whose key is empty. The geo + map pair uses that to exempt addresses cleanly.
  • Inheritance. A location with its own limit_req doesn't inherit the server-level ones. Repeat every zone you want to apply there.

Two gotchas we hit while testing

  • return bypasses limit_req. Our first test config answered with return 200 "ok"; and never got a 429. return runs in Nginx's rewrite phase, before the phase where limit_req runs. Limits apply to requests that get past the rewrite phase (proxy_pass, fastcgi_pass, static files), not to responses produced by return.
  • Sequential curl won't trip a per-second limit. A shell loop is slower than you think. Test with concurrent requests (ab, hey, wrk, or a few lines of Node) or temporarily use a per-minute rate.

Rolling out safely

Turn on limit_req_dry_run on; (Nginx 1.17.1+) first. Requests are counted but not rejected, and with limit_req_log_level warn the would-be rejections appear in the error log as limiting requests … by zone "per_ip". Watch a normal day of traffic, adjust rate and burst, then remove the dry run.

IPv6 in Nginx

limit_req keys on the full IPv6 address, and Nginx has no built-in way to key on a /64 or /56. A client with a large IPv6 allocation can rotate addresses to get around a per-address limit. Use Nginx limits as the coarse outer layer, and do the subnet-aware limit in the application (ipKeyGenerator above), or with a scriptable proxy (OpenResty/njs) if you need it at the edge.

Which layer should limit?

ConcernNginx limit_reqApp middleware
Floods and scrapersBest: rejected before the app worksWorks, but the app still accepts the connection
Login / signup / password resetGood coarse limitBest: can key on account, email, API key
IPv6 subnetsFull address only/56 or /64 grouping
Per-plan or per-API-key quotasNoYes
Several app serversOne shared count per Nginx instanceNeeds a shared store

Most production setups use both: a generous Nginx limit per IP, plus tighter, smarter limits in the app on the endpoints that matter.

Beyond single IPs

Some abuse comes from many addresses in one network, such as a cloud provider or a hosting company. Limiting per network (ASN) catches it without punishing residential users. ASN lookup by IP shows an Express limiter keyed on the ASN from GET https://ipapi.gr/api/lookup/{ip}?key=…, with a fallback for "No Data". If the noise is from crawlers, check first whether they're verified bots you want to keep: see how to verify AI crawler IPs.

FAQ

What status code and headers should a rate-limited response have?

429 Too Many Requests with Retry-After (seconds until the client may retry). express-rate-limit sends both. Nginx sends the status (with limit_req_status 429) but no Retry-After. If your clients use it, route the 429 to a named location inside the server block:

nginx
error_page 429 = @too_many;
location @too_many {
    add_header Retry-After 1 always;
    return 429 "Too many requests\n";
}

Should I rate limit by IP at all if users share addresses?

Yes, with limits generous enough for a busy office or a mobile carrier's NAT, and tighter limits keyed on something more specific (account, API key, session) where you have it. Per-IP limits are the fallback for anonymous traffic.

My limits suddenly hit everyone at once. Why?

Almost always the IP has become the proxy's address again: a new load balancer subnet that isn't in trust proxy or set_real_ip_from, or a CDN in front of an origin that only trusted the load balancer. Log the address the limiter sees and compare it with the client's real IP.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.