Skip to main content
Blog

Server

Securing nginx: 10 items to review before the expertise

The web server is the first layer in front of your app. Hiding the version, default_server, TLS, limits, hidden files, restricting PHP execution, rate limiting and logging.

· 11 min read

Photo: NOIRLab/NSF/AURA/T. Slovinský, CC BY 4.0 · Wikimedia Commons

However carefully the application code is written, every request passes through the web server first. nginx defaults are designed to "just work", not for security: it announces its version, answers for unknown domains, serves any file in the directory and is happy to execute any .php file. During the expertise, scanners and specialists check exactly these things first.

In this article we give 10 practical items for reviewing your nginx configuration before the expertise, each with a test command and a ready-to-use setting. The examples target a PHP (Laravel) application, but most of them also apply to a configuration that proxies to Node or another backend.

Why this matters for the expertise

Web server misconfigurations usually show up in the expertise report as "information disclosure", "misconfiguration" or "cryptographic weakness". Of the six findings in the expertise of our own product, two belonged directly to the server layer: host header injection (part of the fix was adding a default_server in nginx) and ports left open to the internet. We have separate articles on both: host header injection and open ports and server hardening.

Before you start, dump the full current configuration and check the syntax after every change:

sudo nginx -T | less        # full configuration with all includes
sudo nginx -t && sudo systemctl reload nginx

1. Hiding the version: server_tokens

By default nginx shows its version in the Server header and on error pages. That tells an attacker which vulnerabilities to try.

To test:

curl -sI https://your-domain.uz | grep -i server
curl -s https://your-domain.uz/nonexistent-page | tail -5

The fix (in the http block):

server_tokens off;

You will still see Server: nginx, but without a version. Also remove the X-Powered-By: PHP/... header the application adds: expose_php = Off in php.ini or fastcgi_hide_header X-Powered-By; in nginx.

2. default_server: rejecting unknown domains

When a request arrives for the IP address or with a foreign domain, nginx sends it to the default_server or, if none is set, to the first server block — usually your main site. That opens the way to host header injection and lets anyone scan your site by IP.

To test:

curl -skI https://SERVER_IP/
curl -skI https://your-domain.uz/ -H "Host: attacker.example"

The fix is a separate "catch-all" block:

server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;   # nginx 1.19.4+
    return 444;
}

3. TLS settings

Old protocols (SSLv3, TLS 1.0, TLS 1.1) and weak ciphers are recorded as a cryptographic weakness in the expertise. Current practice is TLS 1.2 and 1.3 only. Rather than hand-building a cipher list, it is convenient to use the "intermediate" profile from the Mozilla SSL Configuration Generator.

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

Also add an HTTP-to-HTTPS redirect and the HSTS header — covered in detail in the security headers article.

To test:

openssl s_client -connect your-domain.uz:443 -tls1_1 </dev/null   # the connection should be refused
nmap --script ssl-enum-ciphers -p 443 your-domain.uz

For a deeper analysis, the open-source testssl.sh tool is handy.

4. Blocking hidden and service files

.env, .git/, composer.json, package.json, backup archives and SQL dumps left in the web root can be downloaded like any other file. .env holds the database password and application keys; .git holds the entire code history.

To test:

for p in .env .git/config .git/HEAD composer.json composer.lock \
         package.json backup.zip db.sql .DS_Store; do
  printf "%-16s %s\n" "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://your-domain.uz/$p)"
done

Every one should return 404 or 403.

Step one is the correct root: in Laravel it must be the public/ directory, not the project root. Then add extra protection:

root /var/www/app/public;

# every path starting with a dot, except .well-known (Let's Encrypt)
location ~ /\.(?!well-known/) {
    deny all;
}

location ~* \.(sql|bak|old|orig|swp|zip|tar|gz|7z|log|ini|sh)$ {
    deny all;
}

autoindex off;

5. Execute PHP only where needed

Many configurations have a location ~ \.php$ block, meaning any .php file gets executed. If an attacker can drop a PHP file into uploads/ through a file upload feature, that immediately becomes code execution. In Laravel, the only entry point is index.php.

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

# only index.php is executed
location = /index.php {
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    include fastcgi_params;
    fastcgi_hide_header X-Powered-By;
}

# any other .php — 404
location ~ \.php$ {
    return 404;
}

The socket path must match the PHP version on your system. For WordPress or apps with several PHP entry points, at minimum disable execution in upload directories:

location ^~ /wp-content/uploads/ {
    location ~ \.php$ { deny all; }
}

Other file upload defences are in a separate article.

6. Request size and time limits

Unlimited request size and long timeouts make it possible to tie up the server with slow requests.

client_max_body_size 10m;      # match the largest upload your app accepts
client_body_timeout 15s;
client_header_timeout 15s;
send_timeout 15s;
keepalive_timeout 30s;
large_client_header_buffers 4 8k;

The values depend on the application: for an API with no file uploads client_max_body_size can be much smaller; for a dedicated location that accepts large documents, larger.

7. Rate limiting

Login, password reset, OTP and SMS endpoints are prime targets for password guessing and abuse of paid services. Alongside application-level limits (Laravel's throttle, for example), a limit in nginx is useful too.

# in the http block
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m  rate=10r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;

# in the server block
limit_req_status 429;
limit_conn perip 20;

location = /login {
    limit_req zone=auth burst=5 nodelay;
    try_files $uri /index.php?$query_string;
}

location /api/ {
    limit_req zone=api burst=20 nodelay;
    try_files $uri /index.php?$query_string;
}

If nginx sits behind another proxy or a CDN, $binary_remote_addr will be the proxy's address and all users will share one limit. In that case, list only the trusted proxy with set_real_ip_from and set real_ip_header.

To test:

for i in $(seq 1 15); do
  curl -s -o /dev/null -w "%{http_code} " -X POST https://your-domain.uz/login
done; echo

After a few attempts you should see 429.

8. Restricting HTTP methods

Methods your application does not use (TRACE, CONNECT and others) should not stay open:

if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$) {
    return 405;
}

For a purely static site, shrink the list to GET|HEAD.

9. Proxy headers

When nginx proxies to Node or another backend, do not blindly forward headers the client sent:

location / {
    proxy_pass http://127.0.0.1:3000;
    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_set_header X-Forwarded-Host "";
    proxy_hide_header X-Powered-By;
}

The backend itself should listen only on 127.0.0.1, not on an external interface — otherwise every protection in nginx can be bypassed.

10. Logging

Without logs you can neither detect an incident nor investigate it. At the same time, logs must not collect sensitive data.

log_format main '$remote_addr - [$time_local] "$request_method $uri $server_protocol" '
                '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                'rt=$request_time';

access_log /var/log/nginx/access.log main;
error_log  /var/log/nginx/error.log warn;

This format uses $uri instead of $request, so query strings stay out of the logs — they sometimes contain tokens or personal data. If you need the query, make that a conscious decision. Logs should be rotated with logrotate and access to them restricted.

One trap: add_header inheritance

If add_header is used inside a nested location block, every add_header from the outer server block stops applying to that location. For example, if you set HSTS and CSP at the server level and then add a CORS header in location /api/, HSTS disappears from API responses. Repeat the needed headers in each location, or move them into a shared file and include it. Always verify the result with curl -sI.

nginx's default configuration "works" — that does not mean it is "secure". Each item is a few lines of config, but in the expertise each one can become a separate finding.

Checklist

  • server_tokens off; X-Powered-By hidden.
  • A default_server block rejects requests for unknown domains and the bare IP.
  • TLS 1.2 and 1.3 only, modern ciphers, HTTP-to-HTTPS redirect, HSTS.
  • root points to public/; .env, .git, backups and dumps return 404/403.
  • autoindex off.
  • PHP executes only for index.php (or the files that need it); disabled in upload directories.
  • client_max_body_size and timeouts fit the application.
  • limit_req on login, OTP and API; the real client IP is resolved correctly behind a proxy.
  • Unused HTTP methods return 405.
  • Proxy headers are controlled; the backend listens only on 127.0.0.1.
  • Logging is on, contains no sensitive data, and is rotated.
  • Every location carries the required security headers.

Conclusion

nginx is the front door of your application. The ten items above are a day's work, but they close most of the "easy" findings that come up most often in the expertise and add a layer of protection against application-level mistakes.

Want your server configuration reviewed before the expertise? Request a security scan — the Claude AI agent quickly analyses your nginx and server settings, and our specialist confirms the findings and helps you close the gaps.