Skip to content

nginx: Block Direct IP Access

Internet scanners — Shodan, Censys, masscan bots — sweep entire IP ranges without knowing any hostnames. If your server answers https://203.0.113.10 with content, it hands them three things for free: a TLS certificate (which reveals your domain names), a fingerprint of the server, and an attack surface that bypasses your virtual-host logic. Websites should be reachable by their name; the bare IP is nobody's business.

This article is part of a three-layer hardening series for nginx:

  1. Block direct IP access (this article) — who is asking at all?
  2. Rate limiting — how often are they asking?
  3. ModSecurity + CRS — what are they sending?

Prerequisites

  • nginx 1.19.4 or later (nginx -v) — required for ssl_reject_handshake
  • At least one site configured with a proper server_name
  • Root or sudo access

How It Works

nginx routes every request to a server block by matching the Host header (HTTP) or the SNI hostname (TLS) against the configured server_name values. When nothing matches — which is exactly what happens when someone requests the bare IP — nginx falls back to the default server. Unless you define one, the first server block becomes the implicit default, and your real site answers requests it was never meant to see.

The fix is an explicit catch-all default_server that rejects everything without a matching name.


The Catch-All Server

Create a dedicated config file, for example /etc/nginx/conf.d/00-default.conf:

server {
    listen      80 default_server;
    listen      [::]:80 default_server;
    server_name _;
    return      444;            # nginx-specific: close connection, no response
}
server {
    listen      443 ssl default_server;
    listen      [::]:443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;    # nginx >= 1.19.4 — no dummy cert needed
}

Two non-obvious details:

return 444 is not a real HTTP status code — it is an nginx-internal instruction to close the connection without sending any response at all. The scanner gets nothing, not even an error page.

ssl_reject_handshake on aborts the TLS handshake immediately (with an unrecognized_name alert) whenever the SNI hostname is unknown or missing. This is the part that prevents the domain leak: no certificate is ever delivered. The older workaround — serving a self-signed dummy certificate on the default server — still completed the handshake and left a self-signed cert in every scanner index, marking the host as interesting. With ssl_reject_handshake there is nothing to index and no dummy certificate to maintain.

The file name 00-default.conf is cosmetic — nginx picks the default by the default_server flag, not by load order. Make sure no other server block carries the flag on the same ports; nginx refuses to start with a duplicate.

Validate and reload:

sudo nginx -t
sudo systemctl reload nginx

Verify

Request the bare IP over HTTPS — the handshake must fail before any certificate is exchanged:

curl -vk https://203.0.113.10/
* OpenSSL/3.0.13: error:0A000458:SSL routines::tlsv1 unrecognized name

And over plain HTTP — the connection closes without a response:

curl -v http://203.0.113.10/
* Empty reply from server

Replace 203.0.113.10 with your server's public IP. Your real sites must still work normally via their hostnames — test one to be sure.

Check what scanners already know

Search your server IP on Shodan or Censys. If your domains appear there via the certificate, they were indexed before this change — the entry ages out after the next scan cycle finds nothing.


Common Issues

Symptom Cause Fix
duplicate default server on nginx -t Another block already has default_server on the same port Remove the flag there — only the catch-all should carry it
Real site suddenly unreachable Site's server_name missing or misspelled, so it fell into the catch-all Fix the server_name in the site's server block
IP still serves a certificate nginx older than 1.19.4, ssl_reject_handshake ignored Upgrade nginx, or use the mainline repo
Monitoring health checks fail Checks hit the bare IP instead of the hostname Point health checks at the hostname, or allow-list them in a separate block