Rate limiting, access control and security headers

limit_req and limit_conn with burst and nodelay, allow and deny rules, basic auth, blocking bad clients, and the headers that reduce real risk.

Rate limiting requests and connections

http {
    # a shared zone keyed by client address: 10 MB holds roughly 160k addresses
    limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
    limit_req_zone $http_authorization zone=perkey:10m rate=100r/s;
    limit_conn_zone $binary_remote_addr zone=conns:10m;
    limit_req_status 429;
    limit_conn_status 429;

    server {
        location /api/ {
            limit_req zone=perip burst=20 nodelay;
            limit_conn conns 20;
            limit_req_log_level warn;
            proxy_pass http://app;
        }

        location /login {
            limit_req zone=perip burst=3 nodelay;   # tight: this is a credential endpoint
            proxy_pass http://app;
        }
    }
}
  • burst is a queue of excess requests; without it, one request over the rate gets a 429 immediately.
  • nodelay serves the burst slots immediately instead of spacing them out, which matters for a page that loads many assets.
  • $binary_remote_addr is four bytes on IPv4 versus up to fifteen for the string form, so the zone holds many more addresses.
  • Behind a proxy or CDN, the client address is the load balancer unless you configure set_real_ip_from and real_ip_header.

Access control and basic auth

location /admin/ {
    allow 10.0.0.0/8;
    allow 192.168.0.0/16;
    deny all;
    proxy_pass http://admin_app;
}

location /internal/metrics {
    auth_basic "restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;
    stub_status;                 # only reachable with credentials
}

# if the file must not be served even if it appears under the root
location ~ /\.(?!well-known) {
    deny all;
    access_log off;
}

# block obvious scanners cheaply
if ($http_user_agent ~* (nikto|sqlmap|masscan)) {
    return 444;                  # close the connection without a response
}
DirectiveControlsCommon mistake
allow/denySource addressApplied after a return already fired
auth_basicA shared credentialPlain HTTP, so the password is in the clear
limit_reqRequests per secondNo burst, so legitimate pages 429
limit_connSimultaneous connectionsSet too low for HTTP/2 multiplexing

Security headers that matter

# hiding the version is cosmetic but cheap
server_tokens off;

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;

# start with report-only, then enforce once the reports are clean
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-report" always;

# note: add_header does not inherit into a location that declares its own
location /static/ {
    expires 30d;
    add_header Cache-Control "public" always;
    # the security headers above are NOT present here unless repeated
}
⚠️
add_header replaces the inherited set as soon as a location uses it. A single caching header in a location block silently drops your security headers for that path, which is why header policy belongs in an include file used everywhere.

FAQ

Why do my rate limits not apply to the real client address?
nginx sees the connection from the load balancer or CDN. Configure set_real_ip_from for each trusted proxy and real_ip_header X-Forwarded-For, then verify the address in the access log.
Is basic auth acceptable in production?
Over HTTPS, for a low-traffic internal tool, yes. It sends a reusable credential with every request and has no logout. For anything user-facing, use real authentication and treat nginx as the network boundary only.

TLS hardening and certificates with Let's Encrypt Logging, metrics and debugging a config

Last refreshed 2026-09-18.