# IP allowlist for nginx and wp-admin

*Lock admin panels, dashboards and WordPress wp-admin to your team’s IPs with nginx, also called an IP whitelist.*

An admin panel behind nginx can drop everyone who isn’t on your list before they ever see a login page. It takes four lines, and two mistakes undo it: trusting the wrong client address behind a proxy, and a location block that never runs.

## What this control covers

- Any HTTP path nginx serves: an internal dashboard, a back office, an MCP server, `/wp-admin/` and `/wp-login.php`.
- IPv4 and IPv6 addresses and CIDR ranges.

## What it doesn’t cover

- Requests that reach the app without passing through this nginx, for example straight to the app’s port or to the origin behind a CDN. Firewall those too.
- The application’s own login: keep SSO or strong passwords with MFA behind the allowlist.

## The plan you need

Any nginx: `allow` and `deny` come from the access module, which is built in. Reading the real client IP behind a proxy needs the realip module, which nginx’s own docs say is not built by default; most distribution packages include it, and `nginx -V` lists it as `--with-http_realip_module`. Source: [nginx: ngx_http_access_module](https://nginx.org/en/docs/http/ngx_http_access_module.html).

## Why an allowlist needs fixed IPs

An IP allowlist is only as good as the addresses on it. Home and mobile IPs change without notice, so a list of your team’s own addresses locks people out every few weeks, and someone ends up adding a broad range or switching the control off.

A shared VPN looks like a fix, but its exit IP belongs to every customer of that VPN server. Allowlisting it lets them in too.

What works is a small set of addresses that belong to your team alone and don’t change: your team’s traffic to the service leaves from them, and nothing else does.

## Add your own IP first

> nginx applies the rules on the next reload. Allow the IP you are on right now, run `nginx -t` before `nginx -s reload`, and keep a shell open on the server so you can revert the file if the panel stops answering you.

## Set it up

1. **Allow your IPs on the admin location.** nginx checks `allow` and `deny` in order and stops at the first match, so the addresses come first and `deny all` last. A refused request gets a 403.

   ```
   location /admin/ {
       allow 203.0.113.10;
       allow 203.0.113.11;
       deny  all;
       proxy_pass http://127.0.0.1:8080;
   }
   ```
2. **For WordPress, lock wp-login.php and wp-admin, but not admin-ajax.php.** Front-end plugins call `/wp-admin/admin-ajax.php` for logged-out visitors, and logged-out forms post to `/wp-admin/admin-post.php`; [WordPress warns](https://developer.wordpress.org/advanced-administration/security/brute-force/) that blocking all of `/wp-admin` can break them. Exact-match locations take priority, so they stay open. The `^~` is what makes it work: without it, the site’s usual `location ~ \.php$` catches `/wp-admin/*.php` first and the allow rules never run.

   ```
   location = /wp-admin/admin-ajax.php { include fastcgi.conf; fastcgi_pass php; }
   location = /wp-admin/admin-post.php { include fastcgi.conf; fastcgi_pass php; }
   
   location = /wp-login.php {
       allow 203.0.113.10;
       allow 203.0.113.11;
       deny  all;
       include fastcgi.conf;
       fastcgi_pass php;
   }
   
   location ^~ /wp-admin/ {
       allow 203.0.113.10;
       allow 203.0.113.11;
       deny  all;
       location ~ \.php$ { include fastcgi.conf; fastcgi_pass php; }
   }
   
   location = /xmlrpc.php { deny all; }
   ```
3. **Decide about xmlrpc.php.** Blocking `/xmlrpc.php` stops a common password-guessing route, but it also breaks the WordPress mobile app, Jetpack and some single sign-on plugins. Keep the block unless you use one of them.
4. **Behind a load balancer or Cloudflare, restore the real client IP.** Behind a proxy, nginx sees the proxy’s address, so allowing it lets everyone in. Trust only the proxy’s own ranges with `set_real_ip_from`, and read the client address from its header; never trust `0.0.0.0/0`, or anyone can send the header themselves. Cloudflare publishes its ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/) and says the list needs updating regularly, so refresh it from a script.

   ```
   # One line per range from https://www.cloudflare.com/ips-v4/ and /ips-v6/
   set_real_ip_from 173.245.48.0/20;
   real_ip_header   CF-Connecting-IP;
   ```
5. **Close the way around the proxy.** If the origin also answers on its public IP, anyone can skip Cloudflare and the header with it. Let the origin’s firewall accept web traffic only from the proxy’s ranges.
6. **Test and reload.** Run `nginx -t`, then `nginx -s reload`.

## Check that it works

1. **From outside, expect 403.** From a network that isn’t on the list, the admin path should answer 403.

   ```
   curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-login.php
   # 403
   ```
2. **Through your fixed IPs, expect the login page.** Run the same command from a device whose traffic to the site leaves from an allowed IP: it should print 200.
3. **Check the public endpoints still work.** A request to `/wp-admin/admin-ajax.php` from outside must not get 403; WordPress usually answers 400 to an empty request, which is fine. Then click through any front-end form or plugin that loads content on the page.

## Where the fixed IPs come from

Teicho sends your team’s traffic to the service through a pair of Gates dedicated to your team, each with a fixed IP. Allowlist both, and the service sees only your people and machines, each signed in as themselves, while everyday browsing still goes straight to the internet. Teicho is in early access.

## FAQ

### Is an nginx allowlist the same as an IP whitelist?

Yes. `allow` and `deny` in nginx are what people mean by whitelisting an IP: listed addresses get in, everyone else is refused.

### Should I return 444 instead of 403?

`return 444` closes the connection without any response, which tells a scanner less. `deny` always answers 403, so for 444 you need `return 444` in a location of its own, as for xmlrpc.php. Either way the request never reaches the app.

### Does nginx allow IPv6 addresses?

Yes. `allow` takes IPv6 addresses and CIDR ranges such as `2001:db8::/32`. If your team’s IPs include IPv6, list them too, or a device that prefers IPv6 is refused.

### What about a long list of addresses?

nginx’s docs suggest the geo module for many addresses. With fixed team IPs the list stays at two lines, which is the point.

### Our CI deploys through the admin API. Will it be blocked?

Hosted CI runners change addresses on every run. Send their traffic through your team’s fixed IPs, or deploy through a path that doesn’t need the admin location.

## Sources

- [nginx: ngx_http_access_module](https://nginx.org/en/docs/http/ngx_http_access_module.html)
- [nginx: ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html)
- [WordPress: brute force attacks](https://developer.wordpress.org/advanced-administration/security/brute-force/)
- [WordPress: AJAX in plugins](https://developer.wordpress.org/plugins/javascript/ajax/)
- [Cloudflare: restoring original visitor IPs](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/)

Plans, limits and steps on this page were checked against the vendor’s documentation on 2 November 2026. Vendors change them; if something here is out of date, tell us at hello@teicho.io.
