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;
}
}
}burstis a queue of excess requests; without it, one request over the rate gets a 429 immediately.nodelayserves the burst slots immediately instead of spacing them out, which matters for a page that loads many assets.$binary_remote_addris 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_fromandreal_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
}| Directive | Controls | Common mistake |
|---|---|---|
allow/deny | Source address | Applied after a return already fired |
auth_basic | A shared credential | Plain HTTP, so the password is in the clear |
limit_req | Requests per second | No burst, so legitimate pages 429 |
limit_conn | Simultaneous connections | Set 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.
Related
TLS hardening and certificates with Let's Encrypt Logging, metrics and debugging a config
Last refreshed 2026-09-18.