Block an IP Address in .htaccess — Require not ip, Done Right

Apache 2.4 deny syntax: why a bare 'Require not ip' 403s everyone, the RequireAll wrap, CIDR/IPv6 forms — and what changes behind a CDN.

Blocking an address in Apache 2.4 is a two-line idea wearing a one-line disguise. The deny-list is <RequireAll> plus Require all granted plus one Require not ip per address — and the wrapper is not decoration. Skip it and the single line Require not ip 203.0.113.66 denies everyone, including you.

The 2.4 deny-list, correctly wrapped

<IfModule mod_authz_host.c>
<RequireAll>
Require all granted
Require not ip 203.0.113.66
Require not ip 198.51.100.0/25
Require not ip 2001:db8::bad:1
</RequireAll>
</IfModule>

Why it has to look like this: Require not can only subtract from a grant — it is never a grant itself. RequireAll demands that every line pass, so the block reads “granted to all, minus these addresses.” Delete Require all granted and the same file grants access to no one: a 403 wall with the attacker and the owner on the same side. Each additional Require not ip is another AND-ed exclusion, so one address per line — which is exactly the shape the generator emits from the deny textarea, with the same comment in the output.

Accepted forms per line: a full IPv4 (203.0.113.66), a partial prefix (10.1 matches 10.1.x.x), CIDR (198.51.100.0/25), a dotted netmask (198.51.100.0/255.255.255.128), IPv6 (2001:db8::bad:1, fe80::/10), and hostnames — though Require not host triggers a reverse-DNS lookup per request: slow, and wrong whenever PTR records are missing or misleading. IPs only, in practice.

The 2.2 syntax you’ll still find everywhere

# Apache 2.2 / mod_access_compat — legacy, do not copy onto a 2.4 host
Order Deny,Allow
Deny from 203.0.113.66

On a host still loading mod_access_compat it works; on a pure 2.4 build it’s an Invalid command 'Deny' and the directory 500s — the second-most-common .htaccess 500 after Options on locked-down hosts. (The 500-error guide covers the AllowOverride side: Require needs AuthConfig.) Any page telling you to mix Order/Deny with Require in one block is describing two different servers.

The opposite shape — allow a list, deny everyone else

For a staging site or an admin-only directory the logic flips: grant the knowns, deny the rest.

<IfModule mod_authz_host.c>
<RequireAny>
Require ip 203.0.113.10
Require ip 198.51.100.0/24
</RequireAny>
</IfModule>

RequireAny is OR — the first matching line grants access and evaluation stops; no match at all means 403. No all granted inside this one: adding it would let everyone in. The allow-list and deny-list are complementary, and the generator keeps them in separate textareas because their wrappers differ — RequireAny vs RequireAll + all granted is not a cosmetic difference.

Behind a CDN, REMOTE_ADDR isn’t the visitor

Front the site with Cloudflare or any reverse proxy and every request’s %{REMOTE_ADDR} is the proxy’s edge IP. A .htaccess deny then either misses the real client entirely or blocks the proxy and 403s your whole audience. Two honest fixes, in order of sanity:

  1. Block at the edge — Cloudflare IP Access rules and equivalent WAF tools drop the traffic before it ever becomes an HTTP request on your server.
  2. mod_remoteip — enabled in the server config (RemoteIPHeader CF-Connecting-IP + RemoteIPTrustedProxy ranges), it restores the real client IP so .htaccess IP rules behave normally again. It cannot be switched on from .htaccess itself — server config only, so it’s a support ticket on shared hosting.

What doesn’t work: reading X-Forwarded-For in an IP rule — Require ip evaluates REMOTE_ADDR, and a rewrite test against the header trusts a client-supplied value unless your proxy is verified to overwrite it.

Scope it, then prove it

A site-wide deny is usually too broad — the common real case is one sensitive path. <Files>/FilesMatch narrows the same pair:

<Files "wp-login.php">
<RequireAll>
Require all granted
Require not ip 203.0.113.66
</RequireAll>
</Files>

And when the condition is more than an address — IP and a suspicious user-agent, or a path pattern — drop to mod_rewrite: RewriteCond %{REMOTE_ADDR} ^203\.0\.113\.66$ above RewriteRule ^ - [F] returns a bare 403 without involving authz at all.

Verify from both sides of the wall: curl -I https://yoursite.example/ from the blocked address must answer 403 Forbidden, from any other 200 — and check a second page on the same visit, because the worst version of a typo’d IP rule 403s everyone and you won’t notice from inside the allowed set. The password-protection guide covers the other half of lockdown — Require valid-user plus IPs in combination (AND-ed for a vault, OR-ed so the office skips the password) — and the generator emits both shapes with the guards already attached.

Frequently asked questions

Why does 'Require not ip X' alone lock out the whole site?

Because not can only restrict a grant, never create one. Apache 2.4 authorization is grant-based: someone — usually Require all granted — has to say yes first, and the not lines then carve exceptions out of that yes. With no positive grant anywhere, nobody is authorized and every visitor gets 403. That's why the deny-list always sits inside <RequireAll> next to all granted.

Does 'Deny from 1.2.3.4' still work?

Only where mod_access_compat — the Apache 2.2 compatibility module — is still loaded. On a pure 2.4 build it's an Invalid command and the whole directory 500s. Any tutorial mixing Order/Allow/Deny into a modern config is describing a server that no longer exists; Require replaces all three.

I'm behind Cloudflare — why doesn't blocking an IP do anything?

Because %{REMOTE_ADDR} is Cloudflare's edge address, not the visitor's — every request arrives from a CF IP. Blocking 'the visitor's IP' either does nothing or 403s Cloudflare for everyone. The clean fix is blocking at the edge (Cloudflare's own IP Access rules run before your server is touched); the server-side fix is mod_remoteip restoring the real client IP from CF-Connecting-IP, which must be enabled in the main config — it can't be turned on from .htaccess.

Can I block an IP from just wp-login.php instead of the whole site?

Yes — scope the RequireAll inside a <Files> container: <Files "wp-login.php"> wraps the same all granted + not ip pair so the 403 applies to that file only. For pattern conditions — an IP plus a user-agent or path — use a rewrite instead: RewriteCond %{REMOTE_ADDR} ^203\.0\.113\.66$ then RewriteRule ^ - [F].

How do I write 'allow only these IPs' instead of 'deny these'?

Invert the logic — allow-lists use <RequireAny> (OR: first match wins) with one Require ip per line and no all granted: everyone not listed is denied. That's the lockdown shape — VPN/office range only — and it's what the generator's IP allow / deny section emits for the allow box.