Fix Wrong Client IP Addresses in Django Behind a Proxy
Get the real client IP in Django behind Nginx, load balancers, or CDNs: X-Forwarded-For parsing, trusted proxy counts, Nginx realip, and spoofing risks.
Problem statement
Every request in Django appears to come from 127.0.0.1, a Docker bridge address such as 172.18.0.3, or a load balancer's private IP. As a result:
- audit logs and login history record the proxy instead of users
- rate limiting and login throttling treat all users as one client, locking everyone out at once
- geolocation, fraud checks, and abuse reports use the wrong address
django-axes, DRF throttling, or custom middleware behave incorrectly
request.META["REMOTE_ADDR"] is the address of the TCP connection that reached the app server. Behind any proxy, that is the proxy. The original client address travels in headers such as X-Forwarded-For, but headers are just text: anyone can send a fake X-Forwarded-For. The fix is to read the header only as far as your trusted proxies vouch for it.
Quick answer
- Map your proxy chain: CDN, load balancer, Nginx, app server. Count the trusted hops.
- Make each proxy append or set
X-Forwarded-Forcorrectly; Nginx uses$proxy_add_x_forwarded_for, or$remote_addrif it is the first hop. - Prefer resolving the client IP at the edge proxy (Nginx
realipmodule or the CDN's header) and passing a single trusted value to Django. - In Django, never take the leftmost
X-Forwarded-Forentry blindly; take the entry at the position that corresponds to your trusted proxy count, counting from the right. - Configure DRF's
NUM_PROXIESand any throttling or security packages with the same trust model. - Block direct access to the app server so headers cannot be injected by bypassing proxies.
- Verify by sending a spoofed header and confirming it is ignored.
Step-by-step solution
1. See what Django receives
A temporary diagnostic view:
from django.http import JsonResponse
def ip_debug(request):
return JsonResponse({
"remote_addr": request.META.get("REMOTE_ADDR"),
"x_forwarded_for": request.META.get("HTTP_X_FORWARDED_FOR"),
"x_real_ip": request.META.get("HTTP_X_REAL_IP"),
})
Request it normally, then with a fake header:
curl -s https://www.example.com/debug/ip/
curl -s -H "X-Forwarded-For: 1.2.3.4" https://www.example.com/debug/ip/
Protect or remove the view after debugging; it reveals infrastructure details.
2. Understand how X-Forwarded-For grows
Each proxy that uses Nginx's $proxy_add_x_forwarded_for (or equivalent) appends the address it received the connection from:
Client 198.51.100.7 -> Load balancer 10.0.0.5 -> Nginx 10.0.1.10 -> Gunicorn
X-Forwarded-For seen by Gunicorn: 198.51.100.7, 10.0.0.5
REMOTE_ADDR seen by Gunicorn: 10.0.1.10 (or 127.0.0.1 over a local socket)
If the client sends its own header X-Forwarded-For: 1.2.3.4, the load balancer appends to it:
X-Forwarded-For: 1.2.3.4, 198.51.100.7, 10.0.0.5
The leftmost value is attacker-controlled. The trustworthy client address is the one added by your first trusted proxy, which is found by counting from the right.
3. Option A: resolve the IP in Nginx with realip (recommended)
Let Nginx determine the client address once, then pass a clean value to Django.
Nginx directly facing the internet: there is no upstream proxy to trust, so the client is $remote_addr. Overwrite whatever the client sent:
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
Nginx behind a load balancer or CDN: trust the header only from those addresses:
# in the http or server block
set_real_ip_from 10.0.0.0/16; # load balancer subnet
real_ip_header X-Forwarded-For;
real_ip_recursive on;
With real_ip_recursive on, Nginx walks X-Forwarded-For from the right, skipping addresses that match set_real_ip_from, and uses the first untrusted address as $remote_addr. Then pass that to Django:
location / {
proxy_pass http://unix:/run/myapp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
Behind Cloudflare, use Cloudflare's published IP ranges in set_real_ip_from and real_ip_header CF-Connecting-IP;.
4. Read the value in Django
With Nginx providing a single trusted value, the Django side is simple:
# core/utils.py
def client_ip(request):
return request.META.get("HTTP_X_REAL_IP") or request.META.get("REMOTE_ADDR")
This is safe only because Nginx always overwrites X-Real-IP, and the app server is not reachable except through Nginx.
If you want REMOTE_ADDR itself to hold the client IP (so third-party packages that read REMOTE_ADDR work), set it in a small middleware placed first:
class RealIPMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
real_ip = request.META.get("HTTP_X_REAL_IP")
if real_ip:
request.META["REMOTE_ADDR"] = real_ip
return self.get_response(request)
5. Option B: parse X-Forwarded-For in Django with a trusted count
If you cannot configure the proxy, parse the header in Django using the number of trusted proxies that append to it:
from django.conf import settings
def client_ip(request):
trusted_proxies = getattr(settings, "TRUSTED_PROXY_COUNT", 1)
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
addresses = [a.strip() for a in forwarded.split(",") if a.strip()]
if len(addresses) >= trusted_proxies:
return addresses[-trusted_proxies]
return request.META.get("REMOTE_ADDR")
With exactly one trusted proxy (Nginx using $proxy_add_x_forwarded_for), the last entry is the address Nginx saw, which is the client. With a load balancer in front of Nginx and both appending, use 2. Getting the count wrong in one direction groups users together; in the other direction, it lets clients choose their IP.
6. Align DRF and security packages
Several tools compute client IPs themselves:
- Django REST Framework throttling: uses
NUM_PROXIES. WithNUM_PROXIES = None(default), it uses the wholeX-Forwarded-Forheader as the identity when present, which is easy to vary. Set it to the number of trusted proxies, or resolve the IP in Nginx and setNUM_PROXIES = 0so DRF usesREMOTE_ADDRafter your middleware rewrites it. - Login protection packages (for example django-axes) have their own IP resolution settings; configure them with the same trusted proxy model.
- Gunicorn access logs:
%(h)sis the connection address. Log%({x-real-ip}i)sinstead to record the forwarded client IP.
7. Block direct access to the app server
All header-based approaches assume clients cannot reach Gunicorn or the origin directly:
- bind Gunicorn to a Unix socket or
127.0.0.1 - in Docker, do not publish the app container's port; publish only Nginx
- behind a CDN or load balancer, allow inbound traffic to Nginx only from its address ranges
8. Verify
# Normal request shows your real public IP
curl -s https://www.example.com/debug/ip/
# Spoofed header must not change the resolved IP
curl -s -H "X-Forwarded-For: 1.2.3.4" -H "X-Real-IP: 1.2.3.4" https://www.example.com/debug/ip/
Verification check:
- the resolved client IP matches your public address (check with an external "what is my IP" service)
- sending fake
X-Forwarded-FororX-Real-IPheaders does not change it - rate limits apply per client, not globally
- Gunicorn cannot be reached from outside the host or private network
Explanation
The TCP connection only knows its immediate peer, so client addresses must be relayed by proxies in headers. Headers can be forged by anyone who sends a request, which makes client IP resolution a trust problem, not a parsing problem. The correct address is the one recorded by the outermost proxy you control or trust; everything to the left of it is unverifiable. Resolving that once at the edge and passing a single overwritten value inward keeps the logic in one place.
Edge cases / notes
IPv6 and mapped addresses
Clients may appear as IPv6 addresses or IPv4-mapped IPv6 (::ffff:198.51.100.7). Normalize with ipaddress.ip_address() before comparing or storing.
The Forwarded header
RFC 7239 defines a standardized Forwarded header. Most Django deployments still use X-Forwarded-For; if your proxy sends Forwarded, apply the same right-to-left trust rules.
Privacy
IP addresses are personal data in many jurisdictions. Store them only where needed, and consider truncating or expiring them in logs.
Testing the trust model
Write tests for your IP helper so refactors cannot quietly change which header entry is trusted. These tests target the X-Forwarded-For parsing helper from Option B:
from django.test import RequestFactory, SimpleTestCase, override_settings
from core.utils import client_ip
@override_settings(TRUSTED_PROXY_COUNT=1)
class ClientIPTests(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
def test_uses_address_added_by_trusted_proxy(self):
request = self.factory.get(
"/", HTTP_X_FORWARDED_FOR="1.2.3.4, 198.51.100.7", REMOTE_ADDR="127.0.0.1"
)
self.assertEqual(client_ip(request), "198.51.100.7")
def test_falls_back_to_remote_addr_without_header(self):
request = self.factory.get("/", REMOTE_ADDR="203.0.113.9")
self.assertEqual(client_ip(request), "203.0.113.9")
The first test encodes the security property: a client-supplied 1.2.3.4 at the left is ignored.
Logging the resolved IP consistently
Use one helper everywhere IPs are recorded (logs, audit tables, throttling) so different parts of the application do not disagree. A logging filter can attach it to every log record for request-scoped loggers:
import logging
import threading
_local = threading.local()
class ClientIPMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
_local.client_ip = client_ip(request)
try:
return self.get_response(request)
finally:
_local.client_ip = None
class ClientIPLogFilter(logging.Filter):
def filter(self, record):
record.client_ip = getattr(_local, "client_ip", None) or "-"
return True
Add %(client_ip)s to your log format and attach the filter to the relevant handlers. Thread-local storage works for sync Gunicorn workers and threads; for async code, use contextvars instead.
Docker networks change the picture
Inside Docker, Nginx in one container sees connections from the Docker network gateway or from a published-port proxy, depending on how ports are published. With userland-proxy enabled (Docker's default), connections through a published port may appear to come from the bridge gateway rather than the real client. Check what $remote_addr shows in Nginx logs inside the container; if it is a Docker address, terminate traffic on a load balancer that sets X-Forwarded-For, use host networking for the edge proxy, or configure Docker without the userland proxy.
Common mistakes
- Using
request.META["HTTP_X_FORWARDED_FOR"].split(",")[0]as the client IP. - Setting
real_ip_headerwithout restrictingset_real_ip_fromto trusted proxies. - Publishing Gunicorn's port in Docker while trusting forwarded headers.
- Leaving DRF's
NUM_PROXIESat its default behind a proxy.
Next steps
- Configure Cloudflare-specific headers with Deploy Django Behind Cloudflare.
- Apply per-client limits with Rate Limit Django Endpoints with Nginx.
- Configure API throttling with Deploy a Django REST Framework API to Production.
- Fix the related scheme problem with Fix Too Many Redirects with Django HTTPS Behind a Proxy.
FAQ
Why does Django show 127.0.0.1 for every request?
REMOTE_ADDR is the address of the connection to the app server, which is Nginx on the same host. The client address is only available in headers forwarded by the proxy.
Is it safe to use the first X-Forwarded-For value as the client IP?
No. Clients can send their own X-Forwarded-For header, and proxies append to it. Use the entry added by your first trusted proxy, counting from the right, or resolve it in Nginx.
How do I get the real client IP behind a load balancer and Nginx?
Configure Nginx's realip module with the load balancer's address range and real_ip_recursive on, then pass $remote_addr to Django in a header Nginx overwrites.
What should NUM_PROXIES be in Django REST Framework?
The number of trusted proxies that append to X-Forwarded-For in front of Django. If Nginx already resolves the client IP and rewrites the header, adjust it accordingly and test with spoofed headers.