IP whitelisting is one of the simplest and most effective access controls available in Nginx — restricting a path, an endpoint, or an entire site to a specific set of known IP addresses, and rejecting everyone else before a request ever reaches your application. I use this constantly for admin dashboards, internal tools, monitoring endpoints, and staging environments that have no business being reachable from the open internet. This guide covers the allow/deny directives in depth, common patterns, testing, and the edge cases — like requests passing through a load balancer or CDN — that trip people up.
How IP Whitelisting Works in Nginx
Nginx’s access control module (ngx_http_access_module, compiled in by default) provides two directives: allow and deny. They’re evaluated in order, top to bottom, and the first match wins.
location /admin/ {
allow 203.0.113.10;
deny all;
}
This says: allow the specific IP 203.0.113.10, and deny everyone else. Order matters — Nginx stops at the first matching rule, so deny all; needs to come after your allow rules, not before.
Prerequisites
- A working Nginx server block.
- Sudo access to edit configs and reload Nginx.
- Knowledge of the specific IP address(es) or CIDR ranges you want to allow (your office IP, VPN exit IP, home connection, etc.). Check your current public IP with:
curl ifconfig.me
Step-by-Step Configuration
Step 1: Identify What You’re Restricting
Decide the scope — an entire site, a specific path (like /admin/ or /wp-login.php), or a specific file. I almost always scope this as narrowly as possible rather than restricting an entire domain, unless the whole site genuinely is internal-only.
Step 2: Basic Single-IP Whitelist
location /admin/ {
allow 203.0.113.10;
deny all;
try_files $uri $uri/ =404;
}
Only requests originating from 203.0.113.10 can reach anything under /admin/. Everyone else gets a 403 Forbidden.
Step 3: Whitelist Multiple IPs
location /admin/ {
allow 203.0.113.10;
allow 203.0.113.25;
allow 198.51.100.4;
deny all;
}
Each allow line adds another permitted address. Order among the allow lines themselves doesn’t matter — only their position relative to deny all; matters.
Step 4: Whitelist a CIDR Range
If you’re allowing an entire office network or VPN subnet rather than individual IPs:
location /admin/ {
allow 203.0.113.0/24;
deny all;
}
This allows the full range from 203.0.113.0 to 203.0.113.255. Use whatever CIDR notation matches your actual network — /24 for a 256-address block, /32 for a single exact IP (equivalent to just listing the IP directly), etc.
Step 5: Test the Configuration
sudo nginx -t
sudo systemctl reload nginx
Then test from an allowed IP and a non-allowed IP (or use a VPN/proxy to simulate a different source) to confirm both directions behave correctly.
Whitelisting an Entire Server Block
For something like a staging environment that should never be publicly reachable at all:
server {
listen 80;
server_name staging.example.com;
root /var/www/staging.example.com;
allow 203.0.113.0/24;
allow 198.51.100.4;
deny all;
location / {
try_files $uri $uri/ =404;
}
}
Placing allow/deny directly in the server block applies it to every request to that server, before any location-specific logic runs.
Combining IP Whitelisting With Basic Auth (Defense in Depth)
For genuinely sensitive endpoints, I like layering IP restriction with HTTP basic authentication — even if someone’s on an allowed network, they still need credentials:
location /admin/ {
allow 203.0.113.0/24;
deny all;
auth_basic "Restricted Area";
auth_basic_user_file /etc/nginx/.htpasswd;
try_files $uri $uri/ =404;
}
Generate the password file with:
sudo apt install apache2-utils -y
sudo htpasswd -c /etc/nginx/.htpasswd admin
(Drop the -c flag when adding additional users to an existing file — it recreates the file from scratch otherwise.)
Whitelisting for Specific File Types or Endpoints
A common pattern I use — restricting access to a monitoring/metrics endpoint that shouldn’t be public:
location /metrics {
allow 10.0.0.0/8;
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:9090;
}
Or restricting a specific sensitive file:
location = /server-status {
allow 127.0.0.1;
deny all;
stub_status;
}
A Complete Example Configuration
Here’s a fuller real-world example — a production site where the main content is public, but the admin panel and a debugging endpoint are IP-restricted:
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/example.com/public;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Public content — no restrictions
location / {
try_files $uri $uri/ /index.php?$args;
}
# Admin panel — office network + VPN only
location /admin/ {
allow 203.0.113.0/24;
allow 198.51.100.55;
deny all;
auth_basic "Admin Access";
auth_basic_user_file /etc/nginx/.htpasswd;
try_files $uri $uri/ /index.php?$args;
}
# Debug/status endpoint — localhost and monitoring server only
location /debug-status {
allow 127.0.0.1;
allow 198.51.100.60;
deny all;
stub_status;
}
# PHP handler
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
Handling Requests Behind a Load Balancer, CDN, or Proxy
This is the single biggest gotcha with IP whitelisting, and it’s worth its own section because it silently breaks setups more often than any other part of this topic.
If your Nginx server sits behind a load balancer, reverse proxy, or CDN (Cloudflare, AWS ALB, etc.), the $remote_addr that Nginx sees on every request is the load balancer’s IP, not the real client’s IP. Your allow/deny rules checking $remote_addr will end up allowing or denying based on the proxy’s address, not the actual visitor — which is almost never what you want.
The fix depends on what’s in front of you:
If using Nginx’s ngx_http_realip_module (compiled in by default in most builds), you can tell Nginx to trust the proxy and extract the real client IP from a forwarded header:
set_real_ip_from 10.0.0.5; # your load balancer's IP
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Once this is configured, $remote_addr (and therefore your allow/deny rules) will correctly reflect the original client IP rather than the proxy’s.
If using Cloudflare specifically, they publish their IP ranges, and you’d set set_real_ip_from for each of their published CIDR blocks (there are documented lists for this that Cloudflare maintains), plus:
real_ip_header CF-Connecting-IP;
I always verify this is actually working after setup, since a silently misconfigured real_ip setup means your entire whitelist is checking the wrong address without any obvious error.
Testing Your Whitelist
From an allowed IP:
curl -I https://example.com/admin/
Should return 200 (or a basic auth prompt, 401, if you layered that on top).
From a non-allowed IP — the easiest way to test this without actually changing networks is with a proxy or VPN service, or by asking a colleague on a different network to test:
curl -I https://example.com/admin/
Should return 403 Forbidden.
Confirm what IP Nginx actually sees, especially important behind a proxy — temporarily add a debug header:
location /whoami {
add_header X-Debug-RemoteAddr $remote_addr always;
return 200 "debug";
}
curl -I https://example.com/whoami
Compare the value in X-Debug-RemoteAddr against your actual public IP (curl ifconfig.me) — if they don’t match, and you’re behind a proxy/load balancer/CDN, that’s your sign you need the real_ip module configuration covered above.
Troubleshooting Common Issues
Whitelisted IP still getting 403. Check X-Debug-RemoteAddr as shown above — nine times out of ten, this is a real_ip misconfiguration where Nginx is seeing the load balancer’s IP, not the client’s.
Whitelist not applying at all — everyone can access it. Check for a location / block or an earlier, broader location match that’s handling the request before it reaches your restricted block. Nginx location matching has specific precedence rules; a more specific location needs to actually match for its allow/deny rules to apply.
CIDR range typo silently allowing too much or too little. Double-check CIDR math — /24 is 256 addresses, /16 is 65,536. If you meant a single IP and wrote /24 by mistake, you’ve accidentally allowed an entire subnet. Use an online CIDR calculator if you’re unsure.
IPv6 clients bypassing an IPv4-only whitelist. If your server has IPv6 enabled and you only wrote IPv4 rules, IPv6 clients aren’t covered by those rules at all. Either explicitly deny IPv6 traffic or add corresponding IPv6 allow rules:
allow 203.0.113.0/24;
allow 2001:db8::/32;
deny all;
Dynamic/changing IP addresses breaking access repeatedly. If you’re whitelisting a home connection with a non-static IP, this will break every time your ISP reassigns it. A VPN with a static exit IP, or dynamic DNS combined with a script that updates the Nginx config, are both more sustainable long-term solutions than manually updating a raw IP address.
Security Considerations
- IP whitelisting is not a substitute for authentication — it’s a network-layer filter, not identity verification. Someone on the same network/NAT as an allowed IP, or an attacker who compromises a machine on that network, inherits the access. Layer basic auth or proper application-level authentication on top for anything genuinely sensitive, as shown earlier.
- Spoofed
X-Forwarded-Forheaders are a real risk ifreal_ip_recursiveandset_real_ip_fromaren’t scoped correctly. If you trust theX-Forwarded-Forheader from any source rather than specifically from your known load balancer’s IP, a malicious client could simply set that header themselves to spoof an allowed IP. Only trustX-Forwarded-For/real_ipfrom IPs you’ve explicitly configured as trusted proxies. - Don’t rely solely on
deny all;at the bottom of a list you maintain by hand for anything with serious consequences — a single missingdeny all;or a misordered rule set leaves the resource fully open. I always test the deny case explicitly, not just the allow case. - Log denied attempts to spot patterns of unauthorized access attempts against restricted endpoints:
location /admin/ {
allow 203.0.113.0/24;
deny all;
error_log /var/log/nginx/admin_denied.log warn;
}
Performance Tips
allow/denychecks are extremely cheap — this is one of the lowest-overhead access control mechanisms available, evaluated early in the request lifecycle before more expensive operations like proxying or PHP execution even begin.- Put IP restriction on the most specific location block possible rather than checking it inside application code — rejecting a request at the Nginx layer avoids spinning up a PHP-FPM worker or hitting a backend at all for traffic that’s going to be denied anyway.
- For large IP lists, consider using the
geomodule instead of dozens of individualallowlines, which can be cleaner and marginally more efficient to evaluate:
geo $allowed_ip {
default 0;
203.0.113.0/24 1;
198.51.100.4 1;
}
location /admin/ {
if ($allowed_ip = 0) {
return 403;
}
}
(I use this pattern selectively — for a handful of IPs, plain allow/deny is simpler and just as fast; geo earns its complexity once you’re managing a genuinely large or frequently-changing list.)
Real-World Use Cases
- A staging server for a client’s e-commerce site, restricted to the agency’s office IP and the client’s own home IP, keeping an unfinished site out of search engines and away from curious visitors entirely.
- A Grafana/Prometheus monitoring stack where the metrics endpoint was restricted to the internal VPC CIDR range plus a dedicated monitoring server’s IP, since metrics data (server load, request patterns) isn’t something that needs to be public even if it’s not catastrophically sensitive.
- A WordPress admin panel, layering IP whitelisting on top of the existing login rate limiting covered in the WordPress guide — even if someone had valid credentials from a phished password, they still couldn’t reach
/wp-admin/from outside the allowed network. - An internal API used only by other services on the same private network, whitelisted to the internal subnet only, with public internet access denied entirely at the Nginx layer rather than relying on the application to reject unauthorized callers.
Best Practices Summary
- Always place
deny all;after yourallowrules, never before. - Scope restrictions as narrowly as possible — specific paths rather than whole domains, unless the whole domain genuinely needs to be internal-only.
- Configure
real_ip_modulecorrectly if you’re behind any load balancer, proxy, or CDN — this is the most common reason whitelists silently fail. - Layer IP restriction with authentication for genuinely sensitive resources; don’t treat network-level filtering as identity verification.
- Test both the allowed and denied cases explicitly after every change.
- Watch out for IPv6 traffic bypassing IPv4-only rules if your server has IPv6 enabled.
IP whitelisting is a small amount of configuration for a meaningful reduction in attack surface — anything sitting behind a properly configured allow/deny block simply isn’t reachable by the vast majority of automated scanners and opportunistic attackers scanning the open internet, which lets you focus your other security efforts on the things that genuinely do need to be public.
Blacklisting Instead of Whitelisting
Everything so far has been about allowing a known-good set of IPs and denying everyone else — but sometimes the situation is reversed: the site is public, and I just need to block a specific set of known-bad IPs (a persistent scraper, a source of abusive traffic, a specific attacker who keeps coming back). The same directives handle this, just inverted:
location / {
deny 198.51.100.99;
deny 203.0.113.50/29;
allow all;
}
Here, everyone is allowed except the specifically listed bad actors. I use this pattern far less often than whitelisting, mostly because blocking by IP is a fairly weak long-term defense against a determined attacker (IPs are cheap and easy to rotate), but it’s genuinely effective against unsophisticated, persistent nuisance traffic from a fixed source — a scraper that hasn’t bothered to rotate IPs, for instance.
For a larger or frequently updated blocklist, I keep the list in a separate, included file rather than inline in the main config, which keeps things maintainable:
# /etc/nginx/blocklist.conf
deny 198.51.100.99;
deny 203.0.113.50/29;
deny 192.0.2.77;
server {
include /etc/nginx/blocklist.conf;
allow all;
...
}
This way, updating the blocklist is just editing one small file and reloading Nginx, without touching the main server configuration at all.
Combining Whitelisting With Fail2ban
IP whitelisting and dynamic blocking solve different problems, and I frequently run them together. Fail2ban watches log files (Nginx’s access log, an application’s auth log) for patterns indicating abuse — repeated failed logins, repeated 404s hunting for vulnerable paths — and automatically adds offending IPs to a firewall-level block, typically via iptables or nftables, sitting below Nginx entirely.
A simple fail2ban jail watching for repeated failed WordPress logins, for example:
[nginx-wp-login]
enabled = true
filter = nginx-wp-login
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime = 3600
This is a genuinely complementary layer to the static allow/deny rules covered throughout this guide — whitelisting handles “this resource should only ever be reachable by known sources,” while fail2ban handles “this IP is currently behaving abusively and should be temporarily blocked regardless of what it’s trying to access.” I typically run both on any server with a public-facing login form.
Frequently Asked Questions
Does IP whitelisting work the same way for HTTPS traffic? Yes — allow/deny operates at the connection level within the relevant server or location block, regardless of whether that block is serving HTTP or HTTPS. The one difference worth knowing: with SNI-based virtual hosting (multiple HTTPS sites on one IP), the correct server block still needs to be matched via the TLS handshake before any allow/deny logic inside it is evaluated, but this happens automatically and doesn’t require extra configuration on your part.
Can I whitelist by hostname instead of IP address, since my allowed source has a dynamic IP? Not directly with allow/deny, which only operates on IP addresses and CIDR ranges. For a source with a changing IP but a stable hostname, you’d need either dynamic DNS combined with a script that periodically resolves the hostname and rewrites the Nginx config, or a VPN solution that provides a fixed exit IP regardless of the client’s actual network.
What’s the performance cost of a very long allow list, say 200+ individual IPs? It’s genuinely minimal — Nginx evaluates these checks efficiently even with a sizable list, and for anything approaching hundreds of entries, the geo module (mentioned earlier) is well-suited and remains fast even with large IP sets, since it’s optimized specifically for this kind of lookup.
Should I whitelist my own home IP for admin access, given that most home IPs change periodically? I generally recommend a VPN with a static exit IP over a home IP for this exact reason — residential ISPs commonly reassign IPs periodically (sometimes as often as every few days with certain providers), which means a home-IP-based whitelist rule silently breaks with no warning until you try to log in and can’t get through.
