Nginx Hardening: The Guide to Amplify Your Security (with everything that can go wrong if you don't) Every default Nginx configuration line you ignore is a door left ajar. This guide shows exactly what each one means in practice — and how to close it, one by one.
Every default Nginx configuration line you ignore is a door left ajar. This guide shows exactly what each one means in practice — and how to close it, one by one.
Nginx was born in 2004 with a different proposal than Apache: to be lightweight, fast, and handle thousands of simultaneous connections without breaking a sweat. In 20 years it became the most widely used web server in the world among top 1 million sites. And, just like Apache, its default configuration is designed to work anywhere — not to be secure everywhere.
The good news is that Nginx has a cleaner and more centralized configuration than Apache. The bad news is that precisely because of this, when something is wrong, it's wrong on all sites at once.
This guide follows the same logic as the previous post about Apache: for each item, we explain what the default configuration does, what an attacker can do with it (with real scenarios), and how to fix it with configuration ready to copy.
Every Nginx response includes a Server header like this:
Server: nginx/1.24.0
And on error pages, an equally revealing footer appears.
Nginx-specific aggravating factor: many people run versions compiled with third-party modules (Brotli, ModSecurity, RTMP) that fall behind on updates.
http {
server_tokens off;
}
To completely hide the header (not just the version), use the headers-more module:
more_clear_headers Server;
more_set_headers "Server: webserver";
When Nginx passes requests to PHP-FPM, it automatically sends a SERVERSOFTWARE variable with the Nginx version. Combined with exposephp = On, it leaks both versions.
The same problem as the previous item, in stereo. An attacker discovers both Nginx and PHP versions with a single request.
In php.ini:
expose_php = Off
In Nginx's PHP block:
location ~ \.php$ {
fastcgi_param SERVER_SOFTWARE "webserver";
fastcgi_hide_header X-Powered-By;
}
The fastcgihideheader directive prevents headers from the upstream from being passed to the client. Always use it.
Nginx accepts any HTTP method the client sends and passes it to the backend application.
DELETE /api/users/123 without proper authenticationserver {
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
}
For REST APIs that need PUT, DELETE, PATCH:
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|PATCH|OPTIONS)$) {
return 405;
}
Nginx does not send HSTS. Even on HTTPS-only sites, the browser makes the first request via HTTP until it receives a redirect.
SSL Stripping: an attacker on public Wi-Fi intercepts the first HTTP request, maintains an HTTP connection with the victim and HTTPS with the real server. The victim never sees the padlock, but types the password anyway.
With HSTS, the browser refuses to speak HTTP with the domain after the first visit. The attack stops working.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
CRITICAL WARNING about always: without this suffix, the header is only sent on successful responses and redirects, not on error responses. With always, it's sent on all. Always use always on security headers in Nginx.
Warning about HSTS itself: once applied with a long max-age, the browser locks the domain to HTTPS. If the certificate expires, users lose access. Start with max-age=300 (5 minutes), validate everything, then increase.
Without this header, any site can embed yours inside an iframe.
Clickjacking: an attacker creates any site (raffle, game) that loads your admin panel inside an invisible iframe, with fake buttons overlaid. The victim — logged in another tab of your system — clicks "win prize" and actually clicks "delete account" in your panel.
add_header X-Frame-Options "SAMEORIGIN" always;
When it receives a file with an ambiguous Content-Type, the browser tries to guess the real type by looking at the content (MIME sniffing).
An attacker sends photo.png with HTML/JavaScript content inside. You validate only the extension. When another victim accesses it, the browser looks at the content, thinks "this is HTML" and executes it. Stored XSS without even needing to bypass a filter.
add_header X-Content-Type-Options "nosniff" always;
When a user clicks on an external link, the browser sends the full origin URL to the destination.
Suppose URLs like:
https://mysystem.com/admin/users?token=eyJhbGciOi...
https://mysystem.com/reset-password?key=abc123
https://myhospital.com/patient/john-silva-ssn-12345/exams
Any external link (or image from another domain, or tracking pixel) causes the entire URL to leak to the destination. Tokens, IDs, sensitive data. US health systems leaked patient identifiers to Facebook because of exactly this.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Full URL on internal links, just the domain on external links via HTTPS, nothing when going from HTTPS to HTTP.
Without this header, any page on the site can request access to camera, microphone, geolocation, USB, sensors.
Imagine an XSS on the corporate site. An attacker injects:
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => sendToAttackerServer(stream))
The victim sees a "allow camera?" popup and since they trust the site, they click allow. The CFO's webcam and microphone streaming live to the attacker.
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
Without CSP, the browser executes any JavaScript that appears on the page, from any origin.
XSS has been in the OWASP top 10 for over 20 years. Without CSP, any flaw becomes arbitrary JavaScript execution with access to cookies and session. With well-configured CSP, even if XSS exists, the browser refuses to execute the malicious script.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
CSP is the hardest header to configure. The policy above breaks sites using Google Analytics, Google Fonts, jQuery via CDN, etc. Always test first with Content-Security-Policy-Report-Only, which reports violations without blocking.
Without these headers, your window shares a process with other windows and resources can be loaded by any origin.
After Spectre and Meltdown (2018), it was discovered that JavaScript could read memory from other processes via CPU side channels. The defense: isolate processes by origin.
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# add_header Cross-Origin-Embedder-Policy "require-corp" always;
COEP Warning: require-corp breaks third-party resources that don't send CORP. If you embed YouTube, Maps, external fonts, leave COEP commented out.
By default, Nginx does not list directories — default victory. But the module is available and many people enable it "just to test" and forget. And more serious: Nginx doesn't have .htaccess, so everything must come from the conf.
Directory listing has already leaked database backups, source code (.git/), private uploads, confidential documents. Even without listing, direct access to .env, .git/config, wp-config.php.bak is trivial for scanners.
# Block files and folders starting with a dot
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
# Exception for Let's Encrypt
location ^~ /.well-known/ {
allow all;
}
# Block dangerous extensions
location ~* \.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|lock)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
# Block specific files
location ~* (composer\.(json|lock)|package(-lock)?\.json|wp-config\.php|configuration\.php|web\.config)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
Why return 404 instead of 403? 403 confirms to the attacker that the file exists there. 404 pretends it doesn't. Defense in depth.
If you have a generic location ~ \.php$, it executes PHP on any path ending in .php — including /uploads/shell.php.
Combine with any upload flaw — weak validation, forged MIME — and you have webshell upload. An attacker sends shell.php, accesses it, and has command execution with the PHP-FPM user. Most common compromise pattern in WordPress, Joomla, Drupal for the past 15 years.
location ^~ /uploads/ {
location ~* \.(php|phtml|php3|php4|php5|pht|phar)$ {
deny all;
return 404;
}
}
The ^~ guarantees priority over regex locations, preventing the global PHP from catching files inside uploads.
On many distros, Nginx comes with TLS 1.0 and 1.1 enabled. These protocols have known vulnerabilities (BEAST, FREAK, POODLE) and were officially deprecated by the IETF in 2021 (RFC 8996).
POODLE, BEAST, FREAK, downgrade attacks. PCI-DSS requires TLS 1.2+. Modern browsers already show a "connection not secure" warning for TLS 1.0/1.1.
ssl_protocols TLSv1.2 TLSv1.3;
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:secp384r1:secp256r1;
ssl_session_timeout 1d;
ssl_session_cache shared:NginxSSL:50m;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com.br/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
Use the Mozilla SSL Configuration Generator to generate custom configurations.
Nginx's default is 1MB for body. Reasonable, but many people increase it to "100M" or "0" (unlimited) without thinking when error 413 appears.
An attacker sends giant requests to exhaust bandwidth, memory, and temporary disk space. Repeated, it crashes the server.
Worse: long timeouts (default) leave the server vulnerable to Slowloris — an attack where the client opens connections and sends bytes slowly to freeze workers. A single attacker crashes Nginx with a 20-line Python script.
http {
client_max_body_size 2m;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Short timeouts = anti-Slowloris
client_body_timeout 12;
client_header_timeout 12;
keepalive_timeout 15;
send_timeout 10;
}
# Increase only in specific locations:
location /api/upload {
client_max_body_size 50m;
}
No limit. The attacker sends as fast as they can.
/loginhttp {
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
limit_conn conn_per_ip 20;
location /login {
limit_req zone=login burst=3 nodelay;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
}
}
}
When Nginx is a reverse proxy, it forwards the headers that the application sends. And applications love to leak:
X-Powered-By: Express
X-AspNet-Version: 4.0.30319
X-Runtime: 0.123456
X-Generator: Drupal 9
X-Drupal-Cache: HIT
An attacker discovers the exact version of the framework and searches for CVEs. Headers like X-Drupal-Cache reveal internal structure.
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
proxy_hide_header X-AspNetMvc-Version;
proxy_hide_header X-Runtime;
proxy_hide_header X-Generator;
proxy_hide_header X-Drupal-Cache;
proxy_hide_header X-Drupal-Dynamic-Cache;
proxy_hide_header Server;
fastcgi_hide_header X-Powered-By;
Use generously. It's one of Nginx's best tools.
Save to /etc/nginx/snippets/security-hardening.conf:
# ============================================================
# SECURITY HARDENING - Nginx
# Include in servers with:
# include snippets/security-hardening.conf;
# ============================================================
# --- HTTP security headers (always 'always'!) ---
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
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;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# --- Block dangerous HTTP methods ---
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
# --- Hide headers leaked by upstream ---
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
proxy_hide_header X-AspNetMvc-Version;
proxy_hide_header X-Runtime;
proxy_hide_header X-Generator;
proxy_hide_header X-Drupal-Cache;
proxy_hide_header X-Drupal-Dynamic-Cache;
fastcgi_hide_header X-Powered-By;
# --- Block files and folders starting with a dot ---
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
location ^~ /.well-known/ {
allow all;
}
# --- Block sensitive files by extension ---
location ~* \.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|lock)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
# --- Block sensitive files by name ---
location ~* (composer\.(json|lock)|package(-lock)?\.json|wp-config\.php|configuration\.php|web\.config)$ {
deny all;
access_log off;
log_not_found off;
return 404;
}
# --- Upload folder without PHP execution ---
location ^~ /uploads/ {
location ~* \.(php|phtml|php3|php4|php5|pht|phar)$ {
deny all;
return 404;
}
}
autoindex off;
And the global nginx.conf:
http {
server_tokens off;
# Size limits
client_max_body_size 2m;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Timeouts (anti-Slowloris)
client_body_timeout 12;
client_header_timeout 12;
keepalive_timeout 15;
send_timeout 10;
# Rate limiting
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# SSL/TLS
ssl_protocols TLSv1.2 TLSv1.3;
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:secp384r1:secp256r1;
ssl_session_timeout 1d;
ssl_session_cache shared:NginxSSL:50m;
ssl_session_tickets off;
}
Complete example HTTPS server:
server {
listen 80;
listen [::]:80;
server_name yourdomain.com.br www.yourdomain.com.br;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name yourdomain.com.br www.yourdomain.com.br;
root /var/www/html;
index index.php index.html;
ssl_certificate /etc/letsencrypt/live/yourdomain.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com.br/privkey.pem;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com.br/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
include snippets/security-hardening.conf;
limit_conn conn_per_ip 20;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SERVER_SOFTWARE "webserver";
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location = /login {
limit_req zone=login burst=3 nodelay;
try_files $uri /index.php?$query_string;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
try_files $uri /index.php?$query_string;
}
}
sudo nginx -t
sudo systemctl reload nginx
curl -I https://yourdomain.com.br
Tools: securityheaders.com, ssllabs.com/ssltest, Mozilla Observatory, and SentinelHub for continuous monitoring.
server_tokens offexpose_php = Off in php.inifastcgiparam SERVERSOFTWARE overriddenfastcgihideheader X-Powered-Byalways (tested with short max-age first!). blocked (with .well-known exception)^~autoindex off in all locationsclientmaxbody_size defined (not 0!)proxyhideheader for common leaksIf you also read the Apache post, it's worth highlighting where the two diverge when hardening:
Nginx is more centralized. It doesn't have .htaccess, so all configuration must be in the main conf. Better for security (fewer surprises scattered around), but requires server access.
The always is an Nginx trap. Headers without always don't appear in error responses. It's the most common Nginx hardening pitfall — you think everything is perfect until someone tests a 404 page and discovers half the headers vanished.
Nginx has excellent native rate limiting. Syntax much simpler than Apache.
The proxyhideheader is a superpower. Especially important because Nginx is the most common reverse proxy server for modern applications (Node, Python, Go).
Directory listing is disabled by default. Different from Apache. Default victory.
The if controversy. In Nginx, if inside location can cause unexpected behavior in some scenarios (worth reading the famous "If is Evil" on the official wiki). For simple filters like the HTTP method one I showed here, it's safe.
Hardening remains a process, not an event. You applied all this today? Great. But tomorrow someone will install a plugin that overrides a config, someone will modify CSP to add an analytics and forget to test, someone will set clientmaxbody_size 0 "just to see if it fixes the upload error".
The only realistic way to keep a server secure long-term is to monitor continuously. Something needs to be watching, every day, that nothing changed for the worse — if a header disappeared, if a new port opened, if the certificate is about to expire, if a new version has a critical CVE.
That's exactly what SentinelHub is for: continuous web security scanning, with real-time alerts, descriptions in English, reports ready to show to management, and detection of everything these two posts covered.
Found it useful? Share with your company's sysadmin. Think they should have done this yesterday? They probably should have.