RE:NODE

Block HTTP/1.0 requests in nginx and Apache, safely

By RE:NODE Team
16 min read


To block HTTP/1.0 in nginx, check $server_protocol and return early: if ($server_protocol = HTTP/1.0) { return 444; } inside the server block. In Apache, match %{SERVER_PROTOCOL} with mod_rewrite and answer 403, or use an <If> block with Require all denied. Both take a minute to write. The part that takes longer is deciding whether you should, because the traffic you are aiming at (old bots, mass scanners, requests with no Host header) is mixed with a handful of legitimate clients that still speak HTTP/1.0 - including, very often, your own reverse proxy. Log first, block second, and in most cases a catch-all default server does most of the useful work without touching HTTP/1.0 at all.

Why HTTP/1.0 traffic is worth looking at#

HTTP/1.0 was replaced by HTTP/1.1 in 1997. Every browser released this century speaks 1.1 at minimum, and over HTTPS they negotiate HTTP/2 or HTTP/3. So when an access log fills with HTTP/1.0 requests, they are not coming from people reading your site. They come from three kinds of client:

  • Mass scanners and banner grabbers. A tool sweeping the whole IPv4 space wants the shortest possible request that gets an answer. GET / HTTP/1.0 followed by a blank line is exactly that: no Host header required, no keep-alive to manage, the server closes the connection when it is done.
  • Old or lazy bots. Scrapers built on ancient HTTP libraries, vulnerability probes copied from decade-old exploit scripts, and home-made crawlers that hard-code the request line.
  • Machines talking to machines. Load balancer health checks, benchmarking tools, monitoring scripts and - the one that catches people - reverse proxies talking to the server behind them.

The interesting property of HTTP/1.0 is that the Host header is optional. HTTP/1.1 makes it mandatory, and both nginx and Apache reject an HTTP/1.1 request without one with a 400 Bad Request. An HTTP/1.0 request without Host is legal, so the server has to guess which site to serve, and it picks the default one for that address and port. That is how scanners discover what is running on an IP address without knowing any of its domain names: they ask without a name and read whatever the default site gives back - a CMS login page, a framework's error page, a stack version in a header.

That is still worth having. A quieter log is easier to alert on, and an application that never sees junk requests never spends a database query on them. Just do not file it under "hardened".

Log before you block#

Before changing any behaviour, find out who is actually sending HTTP/1.0 to you. The default combined log format in both servers already records it, because the request line - method, path and protocol - is one field.

bash
# How many HTTP/1.0 requests, out of how many in total$ grep -c 'HTTP/1.0"' /var/log/nginx/access.log$ wc -l < /var/log/nginx/access.log# Which user agents send them, most frequent first$ grep 'HTTP/1.0"' /var/log/nginx/access.log \    | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20# Which addresses send them$ grep 'HTTP/1.0"' /var/log/nginx/access.log \    | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

The Apache equivalent is the same commands against /var/log/apache2/access.log on Debian and Ubuntu, or /var/log/httpd/access_log on RHEL-family systems. The awk -F'"' trick works because in the combined format the user agent is the third quoted field.

What you are looking for in the output:

  • Your own addresses. 127.0.0.1, a private range such as 10.0.0.0/8, or the address of a load balancer. These are machines you run, and blocking them breaks something.
  • Recognisable tools. ApacheBench, a monitoring product's name, HAProxy health checks, an uptime service. Each of these is somebody's job.
  • Empty or meaningless user agents from a long tail of addresses, hitting /, /.env, /wp-login.php, /phpmyadmin/ and similar. This is the traffic you want gone.

Let it run for a week rather than an afternoon. Weekly cron jobs, monthly reports and the payment provider that calls back twice a year all show up eventually, and a week is the shortest window that catches most of them. Logs worth keeping covers how long to retain access logs so that this kind of check is possible after the fact.

In nginx you can also write HTTP/1.0 requests to a separate file, which makes the week of watching painless. The if= parameter on access_log logs a request only when the variable is non-empty and not 0:

/etc/nginx/conf.d/http10-log.conf
map $server_protocol $is_http10 {    default     0;    "HTTP/1.0"  1;}
nginx
server {    # ... your existing configuration ...    access_log /var/log/nginx/access.log combined;    access_log /var/log/nginx/http10.log combined if=$is_http10;}

Apache does the same with SetEnvIf, which can match on Request_Protocol directly, and a CustomLog that only fires when the variable is set:

/etc/apache2/conf-available/http10-log.conf
SetEnvIf Request_Protocol "^HTTP/1\.0$" old_httpCustomLog ${APACHE_LOG_DIR}/http10.log combined env=old_http

Enable it with a2enconf http10-log and reload. On RHEL-family systems, drop the file into /etc/httpd/conf.d/ and replace ${APACHE_LOG_DIR} with logs.

The catch-all default server first#

Most of what people want from "block HTTP/1.0" is really "stop answering requests that do not name one of my sites". That has a cleaner fix that works for every protocol version: a default server that refuses everything, so only requests carrying a real hostname reach a real site.

no Host or unknown nameknown HostClientany HTTP versionnginxport 80 / 443Catch-alldefault_server, 444Your siteserver_name matchesApplication
Where a request lands depends on its Host header

In nginx, this is a server block marked default_server on each port. Requests without a Host header, and requests with one that matches no server_name, all land here:

/etc/nginx/sites-available/00-catchall.conf
server {    listen 80 default_server;    listen [::]:80 default_server;    server_name _;    return 444;}server {    listen 443 ssl default_server;    listen [::]:443 ssl default_server;    server_name _;    ssl_reject_handshake on;}

444 is not a real HTTP status. It tells nginx to close the connection without sending anything, so the client learns nothing - not even which server software answered. On port 443 the TLS handshake happens before any HTTP request, so return 444 alone would need a certificate to present first; ssl_reject_handshake on (nginx 1.19.4 and later) refuses the handshake for names you have not configured, with no certificate needed. On older nginx you have to give the catch-all a self-signed certificate. The nginx reverse proxy guide covers the rest of the layout, including why only one block per port may carry default_server.

Apache has no default_server keyword. The first VirtualHost loaded for an address and port is the default, which is why Debian names its default site 000-default.conf. Replace its contents with a vhost that denies everything:

/etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>    ServerName default.invalid    <Location />        Require all denied    </Location>    ErrorLog ${APACHE_LOG_DIR}/default-error.log    CustomLog ${APACHE_LOG_DIR}/default-access.log combined</VirtualHost>

Apache cannot drop a connection silently the way nginx can, so this returns 403 Forbidden. The .invalid top-level domain is reserved and will never resolve, which guarantees no real request matches it by name.

With the catch-all in place, a Host-less HTTP/1.0 request already gets nothing useful. For many sites that is enough, and the remaining HTTP/1.0 traffic - requests that do name your domain - is harmless enough to leave alone. If you still want it gone, read on.

Blocking HTTP/1.0 in nginx#

$server_protocol holds the protocol the client used: HTTP/1.0, HTTP/1.1, HTTP/2.0 or HTTP/3.0. The simplest block compares it directly:

nginx
server {    listen 80;    server_name example.com www.example.com;    if ($server_protocol = HTTP/1.0) {        return 444;    }    # ... the rest of the site ...}

The usual warning about if in nginx being dangerous applies to if inside a location combined with other directives. An if at server level containing only return is one of the cases the nginx documentation describes as safe.

Match HTTP/1.0 positively. A rule written as if ($server_protocol != HTTP/1.1) looks equivalent and is not: it also blocks every HTTP/2 and HTTP/3 visitor, which on an HTTPS site is nearly all of them.

The map approach, with exceptions

A bare if blocks everybody. The moment you need an exception - your monitoring server, the load balancer, 127.0.0.1 - a map reads better and lives in one place for every site. geo marks trusted addresses, and map combines the two variables into one decision:

/etc/nginx/conf.d/http10-block.conf
geo $http10_trusted {    default        0;    127.0.0.1      1;    10.0.0.0/8     1;    203.0.113.25   1;   # monitoring host}map "$server_protocol:$http10_trusted" $http10_block {    default          0;    "HTTP/1.0:0"     1;}

Then in each server block that should enforce it:

nginx
if ($http10_block) {    return 444;}

Files in conf.d are included inside the http context, which is where map and geo have to live. Both are evaluated lazily, only when the variable is used, so defining them costs nothing on sites that do not reference them. To exempt a user agent rather than an address, add $http_user_agent to the map source and use regex keys, but remember that a user agent is whatever the client says it is.

444, 426 or 403

Which response to send depends on who you expect to read it.

ResponseWhat the client seesUse it when
444Connection closed, no bytesThe traffic is junk and owed nothing
426Upgrade Required, with a reasonA real client might need telling
403ForbiddenYou want an ordinary, well-understood refusal

426 Upgrade Required is the polite, standards-shaped answer: it says the request was understood and that a newer protocol is needed. The HTTP specification expects an Upgrade header to name the protocol, and in nginx add_header is allowed inside if only at location level, so the full version looks like this:

nginx
location / {    if ($http10_block) {        add_header Upgrade "HTTP/1.1" always;        add_header Connection "Upgrade" always;        return 426 "HTTP/1.1 or newer is required.\n";    }    # ... proxy_pass, try_files, etc ...}

always matters: without it, add_header only applies to a short list of success and redirect codes and the headers would silently vanish from the 426. In practice, 444 is the right choice for most sites. A scanner does not read status lines, and a legitimate client that gets an empty reply produces a clear error (curl: (52) Empty reply from server) that tells you exactly where to look.

Blocking HTTP/1.0 in Apache#

Apache gives you three ways to do it. Pick one; mixing them makes the next person's debugging miserable.

mod_rewrite on SERVER_PROTOCOL or THE_REQUEST

This is the version most guides show, and it works in both the main configuration and .htaccess:

.htaccess or <VirtualHost>
RewriteEngine OnRewriteCond %{SERVER_PROTOCOL} =HTTP/1.0RewriteRule ^ - [F]

%{SERVER_PROTOCOL} holds the protocol string, and the = prefix makes it an exact string comparison rather than a regex. [F] returns 403 Forbidden and stops processing. To send 426 instead, use RewriteRule ^ - [R=426,L] - when the status given to R is outside the 3xx range, mod_rewrite drops the substitution and just returns that code.

%{THE_REQUEST} is the full original request line, such as GET /index.php HTTP/1.0. Matching on it is equivalent for this purpose:

code
RewriteCond %{THE_REQUEST} HTTP/1\.0$RewriteRule ^ - [F]

The difference matters elsewhere: THE_REQUEST is never changed by earlier rewrites or internal redirects, which is why it is the variable people reach for when blocking on the raw request. For the protocol check, SERVER_PROTOCOL reads more clearly. Exceptions are one more condition: RewriteCond %{REMOTE_ADDR} !^10\. placed before the rule skips the private range.

Inside .htaccess, mod_rewrite only works if the directory's AllowOverride includes FileInfo (or All). If the rules seem to do nothing, that is the first thing to check, followed by whether mod_rewrite is enabled at all (a2enmod rewrite on Debian).

Require with an expression, no mod_rewrite

Apache 2.4's expression parser knows SERVER_PROTOCOL too, so the same rule can be written with access control instead of rewriting:

<VirtualHost> or .htaccess
<If "%{SERVER_PROTOCOL} == 'HTTP/1.0' && ! -R '10.0.0.0/8'">    Require all denied</If>

-R tests whether the client address is inside a subnet. Alternatively, reuse the old_http variable from the logging section and write Require not env old_http inside a <RequireAll> block next to Require all granted. Both forms return 403. This approach is easier to read than rewrite rules and does not interact with whatever rewriting your CMS already does, which on a WordPress or Laravel .htaccess is a real advantage.

What HttpProtocolOptions does and does not do

HttpProtocolOptions appears in almost every answer to this question, and it does not block HTTP/1.0. Added in Apache 2.4.24, it controls how strictly the request line and headers are parsed. Its default is Strict LenientMethods Allow0.9, and the option people reach for is:

apache2.conf or <VirtualHost>
HttpProtocolOptions Strict Require1.0

Require1.0 rejects HTTP/0.9 - the pre-1996 form where the request is just GET / with no version and no headers. HTTP/1.0 and newer are still accepted. It is worth setting, since nothing legitimate has spoken 0.9 in decades, but it is a different fix for a different (and smaller) problem. Strict is already the default and makes Apache reject malformed request lines and headers with 400; leave it on unless an old client forces you to use Unsafe.

Testing with curl#

curl can speak HTTP/1.0 on request, which makes the checks quick. Run them from a machine that is not on your exception list.

bash
# HTTP/1.0 with a Host header - should be blocked if you block 1.0$ curl --http1.0 -sv -o /dev/null http://example.com/# HTTP/1.0 with no Host header - should hit the catch-all$ curl --http1.0 -sv -o /dev/null -H "Host:" http://203.0.113.10/# HTTP/1.1, the control - should work normally$ curl --http1.1 -sv -o /dev/null http://example.com/# Over HTTPS, where browsers will negotiate HTTP/2$ curl --http1.0 -sv -o /dev/null https://example.com/

-H "Host:" with nothing after the colon tells curl to remove the header it would otherwise add, which is the only way to make it send a Host-less request. What to expect:

  • A 444 from nginx appears as curl: (52) Empty reply from server.
  • A 403 or 426 appears as < HTTP/1.1 403 Forbidden or similar in the verbose output. Note that the server answers with its own highest version in the status line even when the request was 1.0; that is normal.
  • An HTTP/1.1 request without a Host header is rejected with 400 by both servers before your rules even run.

For the rawest possible test, with no client logic in the way, write the request yourself:

bash
$ printf 'GET / HTTP/1.0\r\n\r\n' | nc example.com 80

After testing, check that your exceptions still work by running the first command from a trusted address, and confirm the logs show the blocked requests with the status you chose. Always run nginx -t or apachectl configtest before reloading - a typo in a map takes every site on the server down, not just the one you were editing.

Legitimate clients you might break#

This is the section to read twice. Each of these still sends HTTP/1.0 by default or in common configurations:

  • nginx itself, as a proxy. proxy_http_version defaults to 1.0. If you have nginx in front of another nginx or an Apache, the backend sees every request as HTTP/1.0, and a protocol block on the backend blocks your entire site. Either set proxy_http_version 1.1; on the front proxy (which you want anyway, for keep-alive and websockets) or only enforce the rule on the outer server.
  • Load balancer health checks. HAProxy's option httpchk sends an HTTP/1.0 request unless you give it a version. A blocked health check marks the backend down, and the load balancer takes the site offline for you.
  • ApacheBench. ab speaks HTTP/1.0 only. Your load test will report every request as failed and you will spend an hour suspecting the application.
  • Monitoring and uptime checks. Some older checks and plugins still default to 1.0. Look for them in the logging week rather than finding out when the alerts stop.
  • Very old command-line tools and embedded clients. Old wget builds, printers, routers, IoT firmware and scripts written against ancient libraries. If one of these posts to a webhook on your site, it stops working silently.
  • Webhooks from older systems. Payment, shipping and ERP integrations are where long-lived legacy code lives. They are rare, which is exactly why a single afternoon of logs misses them.

There is a subtler version of the first problem: a proxy you do not control. When a site sits behind a CDN, a hosting provider's front end or any other reverse proxy, the HTTP version you see is the one the proxy used to reach you, not the one the visitor used. On RE:NODE app and web plans, a domain attached through the plan's proxy slot works this way: the slot handles the certificate and passes the real client address in X-Forwarded-For. Check $server_protocol (or %H in Apache's log format) on your own server before writing a rule: if every request arrives with the same version, a protocol check at that layer either blocks nothing or blocks everything. On a dedicated server where your nginx is the first thing on the internet, the version you log is the visitor's own.

What this does not stop#

Being clear about the limit saves disappointment. A client that sends HTTP/1.1 with a valid Host header passes straight through every rule in this post, and modern scanning frameworks, credential-stuffing tools and scrapers all do. The protocol check filters out the laziest layer of background noise; it does nothing about a determined attacker or a well-written bot.

For the traffic that gets past it, the tools are different. Rate limiting caps how fast any single client can hit expensive endpoints like login forms. Fail2ban reads logs and bans addresses at the firewall after repeated bad requests - and the http10.log from the logging section makes a simple jail input if you want persistent offenders banned outright. Firewall rules that matter covers closing every port that does not need to be open, which removes far more attack surface than anything done at the HTTP layer. For a WordPress site, WordPress security hardening deals with the paths these scanners are usually looking for.

A sensible order of work, then: set up the catch-all default server, log HTTP/1.0 for a week, add exceptions for everything of yours that shows up, block with 444 in nginx or 403 in Apache, test with curl, and keep the separate log running so you notice the day something legitimate starts failing.

FAQ#

Will blocking HTTP/1.0 affect search engine crawlers?

No. Googlebot, Bingbot and the other major crawlers use HTTP/1.1 or HTTP/2. If an obscure crawler you care about appears in your HTTP/1.0 log during the logging week, add an exception for it before blocking.

Should I use 444 or 403 in nginx?

444 for traffic you are sure is junk, because it gives the client nothing - not even confirmation of what server answered. 403 or 426 when a real client might be affected and its owner needs a readable error to work out what changed.

Does HttpProtocolOptions Require1.0 block HTTP/1.0 in Apache?

No, despite the name. It requires a version of at least 1.0, which means it rejects HTTP/0.9. To refuse HTTP/1.0 itself, use a mod_rewrite condition on %{SERVER_PROTOCOL} or an <If> block with Require all denied.

Can I block requests without a Host header but allow HTTP/1.0 with one?

Yes, and it is often the better compromise. A catch-all default_server in nginx, or a first-loaded deny-all VirtualHost in Apache, receives every request that names no known site, including all Host-less ones. Requests that name your domain are unaffected, whatever their version.

Why did my whole site go down after I blocked HTTP/1.0?

Almost certainly because another proxy sits in front of the server you changed and talks to it over HTTP/1.0 - nginx does this by default. Remove the rule from the backend, apply it on the outer proxy, and set proxy_http_version 1.1 there.

Does this help against a DDoS attack?

Not meaningfully. Flood traffic does not need HTTP/1.0, and by the time nginx closes a connection it has already been accepted. Volumetric attacks are dealt with upstream of the web server, not in its configuration.

Was this helpful?