Security Hardening

Rate limiting with limit_req, hiding server tokens, and common misconfigurations to avoid.

Rate limiting with limit_req

Without any rate limiting, a single client — a misbehaving script, a brute-force login attempt, a scraper — can send requests as fast as the network allows, consuming resources meant for everyone else. limit_req caps how many requests a client (identified by a key you choose, typically its IP address) can make in a given time window:

Nginx
http {
    limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    server {
        listen 80;
        server_name example.com;

        location /login {
            limit_req zone=login_limit burst=3 nodelay;
            proxy_pass http://127.0.0.1:3000;
        }

        location /api/ {
            limit_req zone=api_limit burst=20;
            proxy_pass http://127.0.0.1:3000;
        }
    }
}
  • limit_req_zone — declared once in the http block, defines a shared memory zone tracking request counts per key. $binary_remote_addr (the client's IP, stored compactly) is the usual key; zone=login_limit:10m names the zone and allocates 10MB (enough to track roughly 160,000 distinct IPs); rate=5r/m sets the sustained limit — 5 requests per minute here, deliberately strict for a login endpoint that has no legitimate reason to be hit rapidly.
  • limit_req — applied inside a location, activates the named zone for that route. burst=3 allows up to 3 requests beyond the steady rate to queue briefly rather than being rejected outright, absorbing a short, legitimate burst (a user double-clicking submit) without penalty. nodelay serves burst requests immediately instead of artificially delaying them to smooth out the rate — appropriate for a login form where you'd rather reject quickly than hold connections open.

A request exceeding the configured limit gets an immediate 503 Service Unavailable by default (configurable via limit_req_status) — the client is refused before ever reaching the backend at all, which is exactly the point: absorbing abusive traffic at the proxy layer protects the application server behind it from ever having to handle the load.

Bash
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/login; done
# 200
# 200
# 200
# 503   <- burst exhausted, further requests rejected until the window resets
# 503

Different endpoints legitimately need different limits — notice the example above sets a much stricter rate on /login (a classic brute-force target) than on the general /api/ prefix, rather than one blanket limit applied everywhere.

Hiding server tokens

By default, Nginx includes its exact version number in every response's Server header and on its default error pages — information that's genuinely useful to an attacker scanning for servers running a version with a known, unpatched vulnerability, and of no benefit at all to a legitimate client:

Nginx
http {
    server_tokens off;
}
Bash
curl -I https://example.com
# Before: Server: nginx/1.24.0
# After:  Server: nginx

server_tokens off; removes the version number specifically while still identifying the software as Nginx — it's a small, low-cost step (not a real security boundary on its own, since hiding a version number doesn't patch anything) that at least removes free reconnaissance information from every single response by default.

Common misconfigurations to avoid

  • Leaving default/example server blocks enabled. A fresh Nginx install often ships a default site (serving a generic welcome page, or worse, directory listing) still enabled — removing or replacing it prevents it from responding to requests for hostnames you never intended to serve at all.
  • Enabling autoindex on a directory that shouldn't be browsable. autoindex on; turns a location with no matching index file into a raw directory listing — appropriate occasionally for an intentional file-download directory, a serious information leak almost everywhere else (it can expose backup files, .git directories, or configuration left in a web root by mistake).
  • Not restricting access to sensitive paths. Configuration files, .env files, or a .git directory accidentally left inside a web root's root path are directly downloadable unless explicitly blocked:
Nginx
location ~ /\.(git|env) {
    deny all;
    return 404;
}
  • Missing security response headers. A few headers cost nothing to add and close off common browser-side attack classes:
Nginx
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

X-Frame-Options prevents the page from being embedded in a hostile <iframe> on another site (mitigating clickjacking); X-Content-Type-Options: nosniff stops a browser from guessing a response's content type against what the server actually declared (mitigating a class of MIME-confusion attacks); Referrer-Policy limits how much of the current URL leaks to external sites your page links to.

  • Trusting X-Forwarded-For from any client directly, when Nginx is the first hop a request reaches. A malicious client can simply set its own X-Forwarded-For header to anything it wants; it should only be trusted when it's known to have been appended by your own infrastructure (an upstream Nginx or load balancer you control), not accepted verbatim from the public internet — the real_ip module's set_real_ip_from directive scopes exactly which upstream addresses are trusted to supply it.

Common mistakes

  • Applying one limit_req rate to every route uniformly instead of setting stricter limits on genuinely sensitive endpoints (login, password reset, checkout) and looser ones elsewhere.
  • Treating server_tokens off as meaningful security rather than a small reconnaissance-reduction step — the underlying software still needs to actually be patched and kept up to date regardless.
  • Leaving autoindex enabled, or forgetting to explicitly deny access to dotfiles and version control directories that might exist inside a web root by accident.
  • Trusting client-supplied X-Forwarded-For unconditionally on a server that's the first hop from the public internet — this lets any client trivially spoof whatever IP it wants for logging or IP-based rate limiting to see.