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.

Client IP through proxiesClientreal addressCDN or balanceradds X-Forwarded-ForNginxrealip moduleDjangotrusted valueTrust the address added by your outermost proxy, never the leftmost header entry.

Quick answer

  1. Map your proxy chain: CDN, load balancer, Nginx, app server. Count the trusted hops.
  2. Make each proxy append or set X-Forwarded-For correctly; Nginx uses $proxy_add_x_forwarded_for, or $remote_addr if it is the first hop.
  3. Prefer resolving the client IP at the edge proxy (Nginx realip module or the CDN's header) and passing a single trusted value to Django.
  4. In Django, never take the leftmost X-Forwarded-For entry blindly; take the entry at the position that corresponds to your trusted proxy count, counting from the right.
  5. Configure DRF's NUM_PROXIES and any throttling or security packages with the same trust model.
  6. Block direct access to the app server so headers cannot be injected by bypassing proxies.
  7. 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)
what each proxy addedX-Forwarded-For:203.0.113.7,198.51.100.20REMOTE_ADDR:10.0.0.51231The client, as the first proxy saw it.2Added by the next proxy: the CDN or LB.3The last hop: Nginx, never the user.Anything left of your first trusted proxy may be forgeddjango-deployment.com
Clients can send their own X-Forwarded-For; proxies append to it.

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.

Let Nginx determine the client address once, then pass a clean value to Django.

realip settings, one by oneValueWhyset_real_ip_fromeach trusted proxy rangeonly these may set the IPreal_ip_headerX-Forwarded-Foror CF-Connecting-IP on Cloudflarereal_ip_recursive✓onskip every trusted hop from the right$remote_addr then holds the client IP for logs and X-Real-IPdjango-deployment.com
Django can then read X-Real-IP, which Nginx sets from $remote_addr.

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")
which entry is the client?Leftmost entry✕whatever the client claimed✕spoof to dodge rate limits✕forged IPs in audit logsCount from the right✓skip N known proxies✓first untrusted address wins✓N matches your real topologyNUM_PROXIES in DRF implements the right-hand countdjango-deployment.com
Changing the proxy chain means updating the count everywhere.

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. With NUM_PROXIES = None (default), it uses the whole X-Forwarded-For header 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 set NUM_PROXIES = 0 so DRF uses REMOTE_ADDR after 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)s is the connection address. Log %({x-real-ip}i)s instead 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
prove nobody can fake their IPGunicorn not reachable directlybound to a socket or 127.0.0.1Forged X-Forwarded-For ignoredlogged IP unchangedLogs, DRF, and rate limits agreesame resolved IPTrusted ranges kept currentCDN ranges changeDirect access to the app server bypasses every rule abovedjango-deployment.com
curl -H "X-Forwarded-For: 1.2.3.4" is the quickest forgery test.

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-For or X-Real-IP headers 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_header without restricting set_real_ip_from to trusted proxies.
  • Publishing Gunicorn's port in Docker while trusting forwarded headers.
  • Leaving DRF's NUM_PROXIES at its default behind a proxy.

Next steps

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.

Keep going