IP Restriction #
One of the most effective ways to protect our digital assets from unauthorized access is network-level access control using IP Restriction. By restricting access based on IP addresses, we can ensure that sensitive areas (like admin panels, monitoring dashboards, or internal staging APIs) can only be reached by authorized parties (e.g., from the office network or internal VPN), while also blocking known malicious attacker IPs directly at the web server level before they burden the application.
Nginx provides the built-in ngx_http_access_module module that lets us arrange allow and deny rules very easily. However, in modern web architecture that often uses Load Balancers, CDNs (like Cloudflare), or additional Reverse Proxies in front of Nginx, IP restriction becomes a bit more complicated. In this article, we’ll discuss the basics of allow and deny rules, the importance of rule ordering, using the geo module for large-scale IP management, and the crucial solution using the Real IP Module so we don’t mistakenly block CDN IPs.
Basic Rules: the allow and deny Directives #
Nginx uses two main directives to control IP-based access:
allow: Allows access for a specific IP address or CIDR block.deny: Denies access for a specific IP address or CIDR block.
We can target a single IP address (both IPv4 and IPv6), a CIDR block (network subnet), or use the all keyword to target all IP addresses on the internet.
Here’s an example of basic configuration inside a location block:
location /admin/ {
# 1. Allow a specific single IP address (e.g., admin IP)
allow 203.0.113.15;
# 2. Allow the office subnet IP range (IPv4)
allow 192.168.1.0/24;
# 3. Allow a custom IPv6 subnet range
allow 2001:db8:1234::/48;
# 4. Deny all other IP addresses outside the list above
deny all;
proxy_pass http://admin_backend;
}
Critical: The Importance of Rule Ordering (Sequence matters) #
Nginx evaluates allow and deny rules sequentially from top to bottom. Evaluation stops immediately when Nginx finds the first matching rule (first match). Therefore, the order of these rules is crucial.
Let’s compare the following order differences to understand the impact:
1. Wrong Pattern (Anti-Pattern) #
location /api/ {
deny all; # <-- ALL IPs are denied here!
allow 10.0.0.0/8; # <-- Will never be executed
}
- Result: The
deny allrule is placed on the first line. When a request comes from IP10.0.0.5, Nginx reads the first line, finds that the IP matches theallcategory, and immediately blocks access (403 Forbidden status). Nginx never reads the second line.
2. Correct Pattern (Best Practice) #
location /api/ {
allow 10.0.0.0/8; # <-- Internal 10.x.x.x IPs are allowed in, evaluation DONE
deny all; # <-- Other external IPs are immediately denied
}
- Result: If a request comes from the internal IP
10.0.0.5, it matches the first line (allow), access is immediately granted, and evaluation stops. If a request comes from the external IP180.250.2.1, it passes the first line because it doesn’t match, then hits the second line (deny all) and is successfully blocked.
[!TIP] Always write specific rules first (like
allow single_IPorallow subnet), then end with a general rule (deny allorallow all) at the very bottom.
Critical Problem Behind CDN/Reverse Proxy: Who Is the Real Visitor? #
In modern web infrastructure, Nginx rarely faces client browsers on the internet directly. Usually, there’s an intermediary layer in front of Nginx like a Cloudflare CDN, AWS Application Load Balancer (ALB), or other internal Reverse Proxies.
flowchart TD
Client["Real Client<br>(IP: 198.51.100.42)"] -->|1. Send Request| CDN["CDN / Cloudflare<br>(IP: 103.21.244.11)"]
CDN -->|2. Forward Request + X-Forwarded-For Header| Nginx["Nginx Server"]
subgraph Nginx Internal Processing
NginxCheck{"Is the real_ip Module Active & Configured?"}
NginxCheck -->|Not Active| Default["Nginx reads the Client IP as the CDN IP (103.21.244.11)"]
NginxCheck -->|Active| Parse["Nginx extracts the header & sets $remote_addr = 198.51.100.42"]
Default --> Apply1["Evaluates allow/deny on the CDN IP (Could Wrongly Block the Entire CDN!)"]
Parse --> Apply2["Evaluates allow/deny on the Real Client IP (Accurate Security)"]
end
classDef danger fill:#ef4444,stroke:#dc2626,color:#ffffff;
classDef success fill:#10b981,stroke:#059669,color:#ffffff;
class Apply1 danger;
class Apply2 success;If we don’t configure Nginx to handle this scenario, we’ll face two major problems:
- Wrong
$remote_addrVariable: Nginx will read the client IP as the CDN’s IP address (e.g.,103.21.244.11), not the visitor’s real IP (198.51.100.42). - Wrongful Blocking: If we write a
deny 198.51.100.42rule, it won’t work because Nginx thinks the request comes from the CDN IP. Conversely, if we writedeny all, Nginx will refuse connections from the CDN, which means we’re blocking ALL visitors to our website!
The Solution: Using the real_ip Module (ngx_http_realip_module)
#
To solve the problem above, Nginx provides a special module called the Real IP Module. This module tells Nginx: “Trust the IP addresses from our Load Balancer/CDN, then look for the client’s real IP address in a specific HTTP header, then replace the $remote_addr variable value with that real IP.”
The HTTP headers commonly used to store the visitor’s real IP are:
X-Forwarded-For: The industry standard used by most load balancers and proxies. Contains a chained list of IPs the data packet passed through.CF-Connecting-IP: A special header inserted by Cloudflare to store the visitor’s real IP.
Configuring the real_ip Module in Nginx #
Here are the steps to configure the real_ip module in Nginx (placed in the /etc/nginx/conf.d/real-ip.conf configuration file or at the http level):
# 1. Register the list of proxy/CDN server IPs we trust
# (The examples below are some public IP ranges belonging to Cloudflare)
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
# If using an internal Load Balancer on AWS/Private Cloud networks:
set_real_ip_from 10.0.0.0/8; # Trust our internal VPC IPs
# 2. Determine which header contains the client's real IP
real_ip_header CF-Connecting-IP; # Use CF-Connecting-IP if using Cloudflare
# real_ip_header X-Forwarded-For; # Use X-Forwarded-For for ALB/general Proxies
# 3. Enable recursive search if there are many proxy hops
real_ip_recursive on;
Explanation of the Key Parameters: #
set_real_ip_from: Nginx only processes the IP replacement if the request comes from an IP address registered here. This is very important for security. If an attacker sends a request directly bypassing the CDN to our server with a forgedX-Forwarded-Forheader, Nginx ignores the fake header because the attacker’s origin IP isn’t registered as a trusted proxy.real_ip_header: Determines the source header for extracting the real IP.real_ip_recursive on: If set toon, Nginx scans the chained IP list in theX-Forwarded-Forheader from right to left, ignoring all IPs registered inset_real_ip_from, and takes the first IP from the right that isn’t trusted as the real client IP.
After the configuration above is active and Nginx is reloaded, the $remote_addr variable will automatically contain the client’s real IP. Our allow and deny rules can now work accurately using the visitor’s real IP even though the server is behind a CDN.
The geo Module: Large-Scale IP Range Management #
If we have to manage hundreds of IP restriction rules (e.g., separating branch office IPs, headquarters IPs, third-party vendor IPs, and bot blacklist IPs), writing allow and deny directives manually in virtual host files will make the configuration very long, complex, and hard to maintain.
The best solution for this problem is the geo module. The geo module lets us define a custom variable whose value depends on the connecting client’s IP address.
Here’s the modular geo implementation pattern in Nginx:
Step 1: Define the IP Map inside the http block
#
# /etc/nginx/conf.d/geo-ip.conf
geo $is_trusted_network {
default 0; # Default: unknown/untrusted IP (value 0)
127.0.0.1 1; # Trusted localhost (value 1)
10.0.0.0/8 1; # Trusted VPC subnet
192.168.1.0/24 1; # Trusted Main Office Wi-Fi subnet
203.0.113.80 1; # Trusted Branch Office Public IP
# We can also load an external file if the list is very large:
# include /etc/nginx/trusted_ips.txt;
}
Step 2: Use the Variable in Server/Location Blocks #
Now, in the virtual host file, we simply check the variable logic with a simple instruction:
server {
listen 443 ssl;
server_name internal-dashboard.example.com;
location / {
# If the client IP isn't from a trusted network (variable value = 0)
if ($is_trusted_network = 0) {
return 403; # Deny with Forbidden status
}
proxy_pass http://internal_backend;
}
}
With this method:
- Our virtual host file stays clean, concise, and easy to read.
- If there are additions or changes to office IP addresses in the future, we only edit the
geo-ip.confmapping file in one place, without touching our sensitive website configuration files.
Case Study: Securing Sensitive WordPress Pages (wp-admin)
#
For CMS users like WordPress, securing the /wp-admin/ page and the wp-login.php file from global brute-force login attacks is a must. We can use IP restriction techniques safely in Nginx:
server {
listen 443 ssl;
server_name mywordpress.com;
root /var/www/wordpress;
index index.php;
# General WordPress routing rules
location / {
try_files $uri $uri/ /index.php?$args;
}
# Special protection for wp-admin and wp-login
location ~ ^/(wp-admin|wp-login\.php) {
# Restrict access: only accessible from our static office IP
allow 203.0.113.80;
allow 192.168.10.0/24; # VPN IP Range
deny all;
# Don't forget to forward PHP processing inside it
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
}
# Handling other general PHP files
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
}
}
IP Restriction Based on Geography (Country) via GeoIP2 #
In some advanced security scenarios, we might want to restrict access to our entire application based on the user’s geographic country origin. For example, if our application only serves local Indonesian users (.id domain), we can block requests coming from abroad to minimize bot traffic scanning for security holes, which mostly originates from overseas server IPs.
To implement this in Nginx, we use the third-party ngx_http_geoip2_module module along with the MaxMind GeoIP2 database.
Step 1: Installing the Module and GeoIP2 Database #
# Install the GeoIP2 module on Ubuntu/Debian
sudo apt install libmaxminddb-dev nginx-module-geoip2 -y
# Download the free MaxMind Country database (GeoLite2-Country.mmdb)
# Move the database file to the Nginx directory
sudo mkdir -p /etc/nginx/geoip/
sudo mv GeoLite2-Country.mmdb /etc/nginx/geoip/
Step 2: Configuring Country Reading in Nginx #
Add the following configuration to the /etc/nginx/nginx.conf file in the http block:
http {
# Load the GeoIP2 module at the top of the file (if dynamic)
# load_module modules/ngx_http_geoip2_module.so;
# GeoIP2 Country database reading configuration
geoip2 /etc/nginx/geoip/GeoLite2-Country.mmdb {
auto_reload 5m; # Reload the database every 5 minutes if there are updates
$geoip2_data_country_code default=ID source=$remote_addr country iso_code;
}
# Create the allowed country mapping logic
map $geoip2_data_country_code $allowed_country {
default 0; # Default: Block other countries (value 0)
ID 1; # Allow Indonesia (value 1)
SG 1; # Allow Singapore (optional for proxy/CDN servers)
}
}
Step 3: Apply the Blocking in the Server Block #
Now, we can deny access from disallowed countries in the server block:
server {
listen 443 ssl;
server_name example.com;
location / {
# If the country origin isn't from an allowed country (value = 0)
if ($allowed_country = 0) {
# Log the blocking activity for analysis
access_log /var/log/nginx/blocked_countries.log;
# Return 403 Forbidden status
return 403 "Access is not allowed from your region.";
}
proxy_pass http://backend;
}
}
Preventing IP Leaks & Spoofing (IP Spoofing Protection) #
When we use the X-Forwarded-For header to extract the client’s real IP, we must be very careful about IP Spoofing attacks. The X-Forwarded-For header is a regular HTTP header that attackers can freely write and modify when sending their initial request.
The Danger of Disabling real_ip_recursive
#
Imagine the following scenario if we disable the recursive feature (real_ip_recursive off;):
- An attacker with the real IP
198.51.100.99sends a request inserting a fake header:X-Forwarded-For: 8.8.8.8(a trusted Google DNS IP). - The request passes through the Cloudflare CDN (Cloudflare IP:
172.64.0.5). - Cloudflare adds the attacker’s IP to the header, making it:
X-Forwarded-For: 8.8.8.8, 198.51.100.99. - The request reaches Nginx. Nginx sees the request comes from the trusted Cloudflare IP
172.64.0.5(because it’s registered inset_real_ip_from). - If
real_ip_recursiveis off, Nginx takes the rightmost IP from the header list,198.51.100.99, as the real client IP. This is safe. - However, if there’s a scenario behind double Load Balancers where we don’t filter the IP list correctly, attackers could manipulate the order.
- By setting
real_ip_recursive on;, Nginx scans all IPs in the header from right to left, ignoring all IPs registered as trusted proxies (set_real_ip_from), and stops at the first IP from the right that isn’t trusted. This guarantees that the fake8.8.8.8IP placed on the left by the attacker is ignored, and Nginx locks onto the attacker’s real IP198.51.100.99.
[!IMPORTANT] Always make sure the
real_ip_recursive on;directive is active, and only put our official Load Balancer/CDN IPs in theset_real_ip_fromlist. Never put wildcard IP addresses or external IPs we don’t control into that trusted list.
Summary #
- Ordering Is Critical: Nginx reads
allowanddenyrules sequentially and immediately stops at the first matching line. Always writeallow specific_IPrules before closing withdeny allat the bottom.- Use the real_ip Module Behind a CDN: If the Nginx web server is behind a Load Balancer or CDN like Cloudflare, you must enable the
real_ipmodule so real IP blocking rules don’t wrongly target CDN IPs.- Apply geo for Large-Scale IPs: Manage hundreds of office IP ranges neatly in one centralized place using the
geomodule to produce a trusted access status variable.- Secure Administrative Endpoints: Restrict access to sensitive administrative paths (like
/admin/or/wp-login.php) to only VPN/internal office IPs to minimize massive brute-force attacks from outside.