Nginx Hardening
Nginx is the most widely deployed web server and reverse proxy on the internet, which makes it a high-value, well-studied target. Its default configuration is reasonably sane out of the box, but production deployments accumulate risk over time: permissive location blocks copied from Stack Overflow, missing request limits, self-signed certs left in place "temporarily," and reverse-proxy configurations that trust client-supplied headers they shouldn't. This guide covers hardening Nginx as a web server and reverse proxy, aligned to the CIS Nginx Benchmark and NIST SP 800-53 Rev. 5 control mappings.
Scope: Nginx (open source / OSS) as a standalone web server or reverse proxy on Linux. For broader multi-server guidance (Apache, IIS) and HTTP security header details, see Web Server Hardening; for certificate lifecycle management, see SSL/TLS Certificates.
1. Installation and Minimal Footprint
- Install from the official
nginx.orgrepository or your distribution's maintained package, not a stale distro-default build โ this keeps you on a supported patch cadence. - Enable only the modules actually in use. If compiling from source, exclude modules you don't need (
--without-http_autoindex_moduleif directory listing is never required, for example); packaged builds load modules dynamically viaload_moduleโ don't load ones you don't use. - Keep Nginx current. Check the running version against the latest stable release and subscribe to the nginx security advisories:
nginx -v
2. Run as a Non-Privileged User, Isolate the Process
Nginx's master process starts as root only to bind privileged ports (80/443) and manage worker processes โ the workers that actually handle requests should run unprivileged.
# /etc/nginx/nginx.conf
user nginx nginx;
Confirm no worker process is running as root:
ps aux | grep '[n]ginx: worker process'
systemd Hardening
If Nginx runs under systemd, layer OS-level sandboxing on top of the user directive via a drop-in override:
# /etc/systemd/system/nginx.service.d/override.conf
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/nginx /var/cache/nginx /var/run
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
AmbientCapabilities=CAP_NET_BIND_SERVICE
CAP_NET_BIND_SERVICE lets the master process bind to ports below 1024 without full root; ProtectSystem=strict makes the entire filesystem read-only to the unit except the paths explicitly listed writable. Test thoroughly in staging โ an overly strict ReadWritePaths will break config reload or logging silently.
3. Suppress Version and Server Information
# http { } block
server_tokens off;
This removes the Nginx version from the Server response header and from default error pages (Server: nginx instead of Server: nginx/1.25.3), denying attackers an easy CVE-matching fingerprint. It does not remove the header entirely โ for that, the headers-more third-party module (more_clear_headers) or a custom build is required.
Also strip upstream application headers that leak stack details when proxying:
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
fastcgi_hide_header X-Powered-By;
4. TLS Configuration
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /etc/nginx/ssl/example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
# Protocols -- TLS 1.2 minimum, 1.3 preferred
ssl_protocols TLSv1.2 TLSv1.3;
# Strong cipher suites (TLS 1.2 -- 1.3 uses its own fixed, already-strong suite set)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # let modern clients pick; TLS 1.3 ignores this directive anyway
# Session resumption
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # disable stateful session ticket reuse across restarts
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/example.com.chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# HSTS -- only on a server block you're certain is always HTTPS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Strong Diffie-Hellman group for legacy DHE ciphers (skip if only using ECDHE above)
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
}
# Redirect all plain HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://$host$request_uri;
}
- Generate a 2048-bit (minimum) or 4096-bit DH param file if any DHE ciphers remain in scope:
openssl dhparam -out /etc/nginx/ssl/dhparam.pem 4096. Prefer ECDHE-only cipher lists above and skip this entirely where compatibility allows. - Verify the deployed configuration with an external scanner (Qualys SSL Labs,
testssl.sh) after every change โ cipher/protocol regressions are easy to introduce silently. - Set the private key file to
600, owned by the user that reads it at startup (typically root, since Nginx reads certs before dropping privileges):
chmod 600 /etc/nginx/ssl/example.com.key
chown root:root /etc/nginx/ssl/example.com.key
5. HTTP Security Headers
Covered in depth in Web Server Hardening โ the Nginx-specific syntax:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
add_header does not inherit into a nested location block that defines its own add_header directives โ repeat every header you need at each level, or centralize them with the third-party headers-more module / an include snippet applied consistently.
6. Request Limits and Denial-of-Service Mitigation
http {
# Cap request body size (uploads) -- return 413 above this instead of buffering unbounded data
client_max_body_size 10m;
# Header/URI size limits -- mitigate abusive or malformed requests
large_client_header_buffers 4 8k;
client_header_buffer_size 1k;
# Timeouts -- mitigate slow-loris style connection exhaustion
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
keepalive_timeout 30s;
keepalive_requests 1000;
# Rate limiting -- define zones in http{}, apply in server/location
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
}
server {
location / {
limit_req zone=general burst=20 nodelay;
limit_conn conn_limit 20;
}
location /login {
limit_req zone=login burst=5 nodelay;
}
}
- Tune
client_max_body_sizeper application โ a global default that's too generous on an API that never accepts uploads is unnecessary exposed surface. - Apply a tighter
limit_reqzone to authentication, password-reset, and other abuse-sensitive endpoints than to general traffic โ credential stuffing and enumeration attacks concentrate there. limit_req/limit_connare per-worker-process in older Nginx versions unlesszonememory is shared correctly (it is, by thezone=directive above) โ verify behavior under load rather than assuming the configured rate is exact under high concurrency.
HTTP/2 Rapid Reset (CVE-2023-44487) Mitigation
http {
http2_max_concurrent_streams 128; # default is already reasonable; lower if abuse observed
keepalive_requests 1000; # recycling connections limits reset-based amplification
}
Ensure Nginx is patched to a version with the upstream fix (1.25.3+ / 1.24.0 with the backported patch) โ configuration tuning alone does not fully close this class of issue.
7. Restrict HTTP Methods
location / {
limit_except GET POST HEAD {
deny all;
}
}
Explicitly deny TRACE (cross-site tracing / header disclosure) and any WebDAV methods (PUT, DELETE, PROPFIND, MKCOL) not intentionally in use โ limit_except denies everything not listed for that location.
8. Directory Listing and Sensitive File Protection
# Never enable in production unless a specific directory intentionally serves an index
autoindex off;
# Deny access to dotfiles and common sensitive artifacts
location ~ /\. {
deny all;
return 404;
}
location ~* \.(git|env|bak|backup|old|swp|log|sql|conf)$ {
deny all;
return 404;
}
# Deny access to version control and dependency directories if ever served from webroot
location ~ /(\.git|\.svn|node_modules|vendor)/ {
deny all;
return 404;
}
Return 404 rather than 403 for these โ a 403 confirms the resource exists but is forbidden, which is itself minor information disclosure to an attacker probing for .git/.env files.
9. Reverse Proxy Hardening
Trust Client IP Headers Only From Known Proxies
If Nginx sits behind a load balancer or CDN, $remote_addr is the LB's IP, not the client's โ but blindly trusting a client-supplied X-Forwarded-For header lets any user spoof their apparent source IP for logging, rate limiting, or WAF allow-lists. Use the realip module scoped to your actual upstream infrastructure only:
# Only trust X-Forwarded-For from your known load balancer / CDN ranges
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 173.245.48.0/20; # example: Cloudflare range
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Never set set_real_ip_from 0.0.0.0/0 โ that trusts the header from anyone, defeating the purpose entirely.
Proxying Upstream Safely
location / {
proxy_pass http://upstream_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
proxy_hide_header X-Powered-By;
proxy_redirect off;
}
- SSRF via proxy misconfiguration: never construct
proxy_passtargets from unsanitized user input (a dynamic$uri/$arg_fed into the upstream host/port). This is a common source of internal-network pivot in reverse-proxy setups. - Set explicit, conservative
proxy_*_timeoutvalues so a slow or hung upstream can't tie up worker connections indefinitely. - If proxying to cloud-hosted upstreams, block requests to cloud metadata endpoints (
169.254.169.254) at the network layer in addition to application-layer controls โ a reverse proxy misconfiguration should not be a path to instance credential theft.
10. Logging
log_format security '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'"$http_x_forwarded_for" rt=$request_time';
access_log /var/log/nginx/access.log security;
error_log /var/log/nginx/error.log warn;
- Include
$http_x_forwarded_forand request timing ($request_time) โ both are valuable for incident investigation and abuse detection. - Never log request bodies,
Authorizationheaders, or session cookies in custom log formats โ access logs are frequently less tightly access-controlled than the application itself, and become a secondary credential-exposure surface if they capture sensitive data. - Forward
access.log/error.logto a central SIEM. Alert on request-rate spikes against a single path, repeated 401/403/404 bursts from one source (probing), and sudden appearance ofTRACE/PROPFIND/other unexpected methods. - Rotate and retain per your compliance requirement (
logrotateis standard on most distros' Nginx packages) โ verify retention actually meets policy rather than assuming the default is sufficient.
11. Configuration and File Permissions
chmod 644 /etc/nginx/nginx.conf
chown root:root /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf
chmod 600 /etc/nginx/ssl/*.key
- Keep TLS private keys unreadable to anyone but root/the process that needs them at startup.
- Don't store upstream API keys or shared secrets in plaintext
proxy_set_headerdirectives inside version-controlled config โ use anincludefile with restricted permissions outside the repo, or inject via a secrets manager into a template at deploy time. - Validate configuration syntax before every reload, and prefer
reload(graceful) overrestart(drops connections):
nginx -t && systemctl reload nginx
12. Web Application Firewall
For applications facing the public internet, terminate common injection, XSS, and scanner traffic in front of the application with ModSecurity (libmodsecurity + the OWASP Core Rule Set) or NAXSI. See Web Application Firewall for deployment guidance and tuning to avoid false-positive lockouts on legitimate traffic.
Hardening Checklist
| Control | Priority | Verify With |
|---|---|---|
| Worker processes run as unprivileged user | Critical | ps aux \| grep 'nginx: worker' |
server_tokens off |
Medium | curl -I response Server header |
| TLS 1.2/1.3 only, strong ciphers | Critical | testssl.sh / Qualys SSL Labs scan |
| HSTS enabled on all HTTPS server blocks | High | Response headers |
| OCSP stapling enabled | Medium | openssl s_client -status |
Private key files 600, root-owned |
Critical | stat /etc/nginx/ssl/*.key |
client_max_body_size set explicitly |
Medium | nginx -T \| grep client_max_body_size |
| Timeouts configured (slow-loris mitigation) | High | nginx -T \| grep timeout |
| Rate limiting on general and auth endpoints | High | nginx -T \| grep limit_req |
| Disallowed HTTP methods denied | Medium | curl -X TRACE / curl -X PROPFIND |
autoindex off everywhere |
High | nginx -T \| grep autoindex |
Dotfiles / .git / .env denied |
Critical | curl a known dotfile path, expect 404 |
X-Forwarded-For trusted only from known proxy ranges |
Critical | nginx -T \| grep set_real_ip_from |
No SSRF-prone dynamic proxy_pass targets |
Critical | Config review of all proxy_pass directives |
| Access/error logs forwarded to SIEM | High | SIEM ingestion config |
| Sensitive data excluded from log format | High | nginx -T \| grep log_format |
| Config files not world-writable | High | stat /etc/nginx/nginx.conf |
| Nginx version current / patched | High | nginx -v vs. latest stable |
| WAF (ModSecurity/NAXSI) in front of public apps | High | Module/rule set presence |
NIST SP 800-53 Rev. 5 Control Mapping
| Control | Title | How Nginx hardening addresses it |
|---|---|---|
| AC-4 | Information Flow Enforcement | realip trust scoping, method restrictions, sensitive-path denial |
| AC-6 | Least Privilege | Unprivileged worker processes, systemd capability restriction |
| SC-5 | Denial of Service Protection | limit_req/limit_conn, timeouts, HTTP/2 stream limits |
| SC-7 | Boundary Protection | Reverse-proxy hardening, SSRF/metadata-endpoint mitigation |
| SC-8 | Transmission Confidentiality/Integrity | TLS 1.2/1.3-only, strong cipher suites, HSTS |
| SC-13 | Cryptographic Protection | OCSP stapling, session ticket rotation |
| SC-28 | Protection of Information at Rest | Restricted key file permissions |
| CM-6 | Configuration Settings | server_tokens off, denied methods, autoindex off |
| CM-7 | Least Functionality | Minimal module set, WAF-scoped rule enforcement |
| AU-2 / AU-3 | Audit Events / Content of Audit Records | Security-focused log_format with client and timing data |
| AU-6 | Audit Review, Analysis, Reporting | SIEM forwarding and alerting on abuse patterns |
| SI-4 | System Monitoring | WAF (ModSecurity/NAXSI), rate-limit and log-based alerting |