Sem Agente · Sem Instalação

Nginx Hardening: Complete Guide

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.

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.

Introduction

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.

1. server_tokens: Nginx also shouts its version

What happens by default

Every Nginx response includes a Server header like this:

Server: nginx/1.24.0

And on error pages, an equally revealing footer appears.

What this causes

  1. An attacker scans the internet with Shodan, Censys, or a script
  2. Filters for "nginx/1.24.0" — finds all CVEs for that version
  3. Filters ready-made exploits on Exploit-DB, Metasploit, GitHub
  4. If your version has an unpatched vulnerability, the attack begins in seconds

Nginx-specific aggravating factor: many people run versions compiled with third-party modules (Brotli, ModSecurity, RTMP) that fall behind on updates.

The fix

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";

2. fastcgiparam SERVERSOFTWARE: the PHP leak

What happens by default

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.

What this causes

The same problem as the previous item, in stereo. An attacker discovers both Nginx and PHP versions with a single request.

The fix

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.

3. Dangerous HTTP methods

What happens by default

Nginx accepts any HTTP method the client sends and passes it to the backend application.

What this causes

  • Poorly protected API that accepts DELETE /api/users/123 without proper authentication
  • Node/Python application with PUT route accidentally left open
  • Misconfigured WebDAV in another component
  • Compliance scanners marking it as a vulnerability

The fix

server {
    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;
}

4. Strict-Transport-Security (HSTS)

What happens by default

Nginx does not send HSTS. Even on HTTPS-only sites, the browser makes the first request via HTTP until it receives a redirect.

What this causes

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.

The fix

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.

5. X-Frame-Options: clickjacking

What happens by default

Without this header, any site can embed yours inside an iframe.

What this causes

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.

The fix

add_header X-Frame-Options "SAMEORIGIN" always;

6. X-Content-Type-Options: MIME sniffing

What happens by default

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).

What this causes

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.

The fix

add_header X-Content-Type-Options "nosniff" always;

7. Referrer-Policy

What happens by default

When a user clicks on an external link, the browser sends the full origin URL to the destination.

What this causes

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.

The fix

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.

8. Permissions-Policy

What happens by default

Without this header, any page on the site can request access to camera, microphone, geolocation, USB, sensors.

What this causes

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.

The fix

add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;

9. Content-Security-Policy: modern defense against XSS

What happens by default

Without CSP, the browser executes any JavaScript that appears on the page, from any origin.

What this causes

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.

The fix

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.

10. Cross-Origin headers (COOP, CORP, COEP)

What happens by default

Without these headers, your window shares a process with other windows and resources can be loaded by any origin.

What this causes

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.

The fix

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.

11. Directory and sensitive file listing

What happens by default

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.

What this causes

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.

The fix

# 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.

12. PHP execution in upload folders

What happens by default

If you have a generic location ~ \.php$, it executes PHP on any path ending in .php — including /uploads/shell.php.

What this causes

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.

The fix

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.

13. SSL/TLS: burying TLS 1.0 and 1.1

What happens by default

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).

What this causes

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.

The fix

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.

14. clientmaxbody_size and timeouts (anti-Slowloris)

What happens by default

Nginx's default is 1MB for body. Reasonable, but many people increase it to "100M" or "0" (unlimited) without thinking when error 413 appears.

What this causes

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.

The fix

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;
}

15. Rate limiting

What happens by default

No limit. The attacker sends as fast as they can.

What this causes

  • Brute force on login — 10,000 passwords per second on /login
  • Brute force on API — enumeration of sequential IDs
  • Aggressive scraping — bot downloads the entire site in seconds
  • DoS by expensive endpoint — attacker repeatedly calls heavy endpoint

The fix

http {
    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;
        }
    }
}

16. proxyhideheader: leak via backend application

What happens by default

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

What this causes

An attacker discovers the exact version of the framework and searches for CVEs. Headers like X-Drupal-Cache reveal internal structure.

The fix

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.

Final configuration: everything together

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;
    }
}

Validating

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.

Final checklist

  • [ ] server_tokens off
  • [ ] expose_php = Off in php.ini
  • [ ] fastcgiparam SERVERSOFTWARE overridden
  • [ ] fastcgihideheader X-Powered-By
  • [ ] HSTS with always (tested with short max-age first!)
  • [ ] X-Frame-Options applied
  • [ ] X-Content-Type-Options applied
  • [ ] Referrer-Policy applied
  • [ ] Permissions-Policy applied
  • [ ] CSP defined (tested in report-only first!)
  • [ ] COOP/CORP applied
  • [ ] Dangerous HTTP methods blocked
  • [ ] Folders/files with . blocked (with .well-known exception)
  • [ ] Sensitive files blocked by extension and name
  • [ ] PHP disabled in upload folders with ^~
  • [ ] autoindex off in all locations
  • [ ] TLS 1.0 and 1.1 disabled
  • [ ] Modern ciphers configured
  • [ ] OCSP Stapling enabled
  • [ ] clientmaxbody_size defined (not 0!)
  • [ ] Short timeouts (anti-Slowloris)
  • [ ] Rate limiting on login and API
  • [ ] proxyhideheader for common leaks
  • [ ] Site tested A+ on securityheaders.com
  • [ ] Site tested A+ on ssllabs.com
  • [ ] Continuous monitoring active

Apache vs Nginx: the differences that matter for security

If 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.

Final thoughts

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.