Nginx Security Configuration: Headers, TLS, and Hardening
A complete Nginx security hardening guide covering TLS 1.3 configuration, security response headers, rate limiting, blocking bad bots, disabling version disclosure, and access logging.
Nginx is the most widely deployed web server and reverse proxy on the internet, and its default configuration is not built with security as the primary goal — it's built for compatibility and ease of use. A few targeted configuration changes dramatically reduce your attack surface. This guide covers the essential hardening steps with production-ready configuration snippets.
TLS 1.3 Configuration
Older TLS versions (1.0, 1.1) have known vulnerabilities — POODLE, BEAST, CRIME, and others. Even TLS 1.2, while still acceptable, is slower than TLS 1.3 and supports weaker cipher suites if misconfigured.
Recommended TLS configuration:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# TLS 1.2 and 1.3 only — disable 1.0 and 1.1
ssl_protocols TLSv1.2 TLSv1.3;
# TLS 1.3 uses its own cipher suites automatically
# For TLS 1.2, restrict to strong AEAD ciphers with forward secrecy
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';
# Prefer server cipher order
ssl_prefer_server_ciphers off; # For TLS 1.3, the client picks; for 1.2, use 'on'
# ECDH curve - X25519 and P-256 are strong choices
ssl_ecdh_curve X25519:prime256v1:secp384r1;
# Session caching (improves performance without weakening security)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # Disable for perfect forward secrecy
# OCSP Stapling - reduces TLS handshake latency
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
}
Why ssl_session_tickets off? Session tickets use a ticket encryption key that, if compromised, allows decryption of all sessions using that key — undermining forward secrecy. Disabling them and using session cache instead maintains performance while preserving PFS.
Security Response Headers
Security headers tell browsers how to behave when handling your site's content. They're your last line of defense against XSS, clickjacking, MIME-type sniffing, and information leakage.
Add these in the http or server block (using http applies them globally to all virtual hosts):
# Prevent clickjacking - disallows your site from being loaded in an iframe
add_header X-Frame-Options "DENY" always;
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Control referrer information sent with requests
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# HSTS - force HTTPS for 1 year
# ONLY add includeSubDomains and preload when ALL subdomains support HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Content Security Policy - restrict resource loading
# Start restrictive and loosen as needed
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Permissions Policy - restrict browser feature access
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
The always parameter ensures headers are added even on error responses (4xx, 5xx), not just 2xx responses.
Content Security Policy Tuning
CSP is the most powerful header but also the most complex. A too-restrictive policy will break your site. Use CSP report-only mode first to collect violations without enforcing:
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report;" always;
Set up a /csp-report endpoint that logs violations to your monitoring system, then tighten the policy based on actual reports.
Disabling Server Version Information
By default, Nginx includes its version number in response headers and error pages. This tells attackers exactly which version you're running, making it trivial to look up known CVEs.
# In the http block
server_tokens off;
This changes the Server: nginx/1.24.0 header to just Server: nginx. For even more obscurity (security through obscurity, not a replacement for patching), you can recompile Nginx with a custom server name, but server_tokens off alone removes the version.
Also suppress PHP version exposure if proxying to PHP-FPM:
fastcgi_hide_header X-Powered-By;
proxy_hide_header X-Powered-By;
Rate Limiting with limit_req
Nginx's built-in rate limiting prevents brute-force attacks, credential stuffing, and API abuse.
http {
# Define rate limit zones
# $binary_remote_addr is more efficient than $remote_addr for large tables
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
limit_req_zone $binary_remote_addr zone=global:20m rate=10r/s;
server {
# Apply rate limiting to login endpoint
location /auth/login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
# ... proxy config
}
# Apply to API
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
# Global rate limiting on all routes
location / {
limit_req zone=global burst=30 nodelay;
}
}
}
Understanding burst and nodelay:
burst=20: Allows up to 20 requests to queue above the rate limit before rejectingnodelay: Queued requests are served immediately (not delayed) — the burst acts as a credit buffer, then requests are rejected once the buffer is full
Return a proper 429 Too Many Requests with a Retry-After header:
error_page 429 = @rate_limited;
location @rate_limited {
add_header Retry-After 60 always;
return 429 '{"error": "Too many requests"}';
}
Blocking Bad Bots and Scanners
Known vulnerability scanners, scrapers, and malicious bots can be blocked by user agent. While sophisticated attackers can spoof user agents, blocking known bad actors reduces log noise and server load.
http {
# Map block - evaluates once per request
map $http_user_agent $bad_bot {
default 0;
~*zgrab 1;
~*masscan 1;
~*nikto 1;
~*sqlmap 1;
~*nmap 1;
~*dirbuster 1;
~*gobuster 1;
~*nessus 1;
~*openvas 1;
"" 1; # Empty user agent
}
server {
if ($bad_bot) {
return 444; # 444 = close connection without response (Nginx-specific)
}
}
}
Also block direct access to sensitive files and directories:
# Block access to dotfiles and sensitive paths
location ~ /\. {
deny all;
return 404;
}
location ~* \.(env|git|svn|htaccess|htpasswd|sql|conf|bak|log)$ {
deny all;
return 404;
}
location ~ ^/(wp-admin|wp-login|phpmyadmin|adminer) {
deny all;
return 404;
}
Request Size Limits
Without size limits, attackers can send multi-gigabyte request bodies to exhaust memory, disk, or CPU. Nginx's defaults are surprisingly permissive.
http {
# Maximum client request body size (default: 1m)
# Set to what your application actually needs
client_max_body_size 10m;
# Limit time to receive request body (default: 60s)
client_body_timeout 10s;
# Limit time for client to send request headers
client_header_timeout 10s;
# Limit request header size (default: 8k)
large_client_header_buffers 2 4k;
# Close keepalive connections after N requests
keepalive_requests 100;
# Keepalive timeout (close idle connections)
keepalive_timeout 65;
# Limit request line size (URI + method + protocol)
client_header_buffer_size 1k;
}
For APIs that only accept JSON, you can also validate the Content-Type header to reject unexpected request formats:
location /api/ {
if ($content_type !~ "application/json") {
return 415;
}
}
Access Logging for Security Analysis
Nginx's default log format captures the basics, but a more structured format makes log analysis in a SIEM or log aggregation system much easier:
http {
log_format security_json escape=json
'{'
'"time": "$time_iso8601",'
'"remote_addr": "$remote_addr",'
'"forwarded_for": "$http_x_forwarded_for",'
'"method": "$request_method",'
'"uri": "$request_uri",'
'"status": "$status",'
'"bytes_sent": "$body_bytes_sent",'
'"referer": "$http_referer",'
'"user_agent": "$http_user_agent",'
'"request_time": "$request_time",'
'"upstream_time": "$upstream_response_time",'
'"ssl_protocol": "$ssl_protocol",'
'"ssl_cipher": "$ssl_cipher"'
'}';
access_log /var/log/nginx/access.log security_json;
error_log /var/log/nginx/error.log warn;
}
Route these logs to your SIEM (Elasticsearch, Splunk, Datadog) for anomaly detection. Key patterns to alert on:
- High rate of 4xx errors from a single IP (scanner activity)
- Requests to known exploit paths (
.env,wp-login.php, etc.) - Sudden spike in
client_max_body_sizerejections (potential upload abuse) - Requests with unusual user agents or empty user agents
A hardened Nginx configuration combining all of the above transforms your web server from a potential entry point into a resilient, well-monitored security layer.