How to get the real client IP in Django, Flask, FastAPI, Laravel, Rails and Go

Every framework has a different switch for trusting proxies, and the most-copied snippets get it wrong. This is the how-to: the setting or middleware for Django, Flask, FastAPI, Laravel, Rails and Go, with tested code and a quick-reference table.

On this page Django

Behind a reverse proxy, your app sees the proxy's address as the client. Each framework has its own way to fix that, and each has a popular snippet that fixes it wrongly. The wrong version usually looks like this one, which appears in many Django tutorials:

python
# Don't use this
def get_client_ip(request):
    xff = request.META.get("HTTP_X_FORWARDED_FOR")
    return xff.split(",")[0] if xff else request.META.get("REMOTE_ADDR")

The first X-Forwarded-For entry is whatever the client sent. Your proxy appends the real address to the right, it doesn't replace the header. With this function, curl -H "X-Forwarded-For: 127.0.0.1" … makes a request look local. This article is the how-to: one setting, middleware or function per stack, each tested. For why the first entry is dangerous, what an attacker can do with it and how the trusted-proxy model works, read X-Forwarded-For spoofing: the security risk.

The rule is the same everywhere: trust forwarding headers only from proxies you run, and read them from the right. Then make sure the app can only be reached through those proxies. Bind it to 127.0.0.1, a Unix socket or a private network.

Django

Django has no built-in trusted-proxy setting. request.META["REMOTE_ADDR"] is the TCP peer, and Django leaves HTTP_X_FORWARDED_FOR alone. Add a small middleware that rewrites REMOTE_ADDR once, so everything downstream (your views, logging, rate-limit packages) uses the right address:

python
# yourproject/middleware.py
import ipaddress

from django.conf import settings


def _parse(value):
    try:
        ip = ipaddress.ip_address(value.strip())
    except ValueError:
        return None
    if ip.version == 6 and ip.ipv4_mapped:  # ::ffff:203.0.113.7 -> 203.0.113.7
        ip = ip.ipv4_mapped
    return ip


class RealClientIPMiddleware:
    """Replace REMOTE_ADDR with the client address, trusting X-Forwarded-For
    only when it was added by one of settings.TRUSTED_PROXIES."""

    def __init__(self, get_response):
        self.get_response = get_response
        self.trusted = [ipaddress.ip_network(n) for n in settings.TRUSTED_PROXIES]

    def _is_trusted(self, ip):
        return any(ip in net for net in self.trusted)

    def __call__(self, request):
        ip = _parse(request.META.get("REMOTE_ADDR", ""))
        if ip is not None and self._is_trusted(ip):
            xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
            for hop in reversed([h for h in xff.split(",") if h.strip()]):
                parsed = _parse(hop)
                if parsed is None:
                    break
                ip = parsed
                if not self._is_trusted(ip):
                    break
            request.META["REMOTE_ADDR"] = str(ip)
        return self.get_response(request)
python
# settings.py
TRUSTED_PROXIES = ["127.0.0.1/32", "::1/128"]  # add your load balancer subnet, e.g. "10.0.0.0/16"

MIDDLEWARE = [
    "yourproject.middleware.RealClientIPMiddleware",  # first, before anything reads the IP
    "django.middleware.security.SecurityMiddleware",
    # ...
]

ip in net returns False rather than raising when the versions differ (an IPv4 address against an IPv6 network), so a mixed TRUSTED_PROXIES list is safe. We tested the middleware on Django 6.1 with a forged first entry (1.2.3.4, 198.51.100.9 from 127.0.0.1 gives 198.51.100.9), two proxy hops, IPv6, IPv4-mapped peers, garbage entries, and a direct client with a forged header (ignored).

Prefer a package? django-ipware / python-ipware is widely used. By default it returns the leftmost address. Its README warns that every default header can be sent by a client, and that without proxy_count or proxy_list the result may be an upstream proxy rather than the client. Configure one of those for anything security-related.

Flask (and any WSGI app): ProxyFix

Werkzeug ships the fix:

python
from flask import Flask
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
# One proxy (Nginx) in front: trust exactly one X-Forwarded-For hop
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

request.remote_addr is then the client. x_for=1 means "take the last entry", and the number must match the proxies you run: 2 for a load balancer plus Nginx.

ProxyFix is a hop count. It doesn't check who connected. We confirmed with Werkzeug 3.1: a request sent straight to the app from 203.0.113.7 with X-Forwarded-For: 6.6.6.6 came out as 6.6.6.6. That's fine when Gunicorn listens on 127.0.0.1 or a Unix socket and Nginx is the only way in. It isn't fine if the app port is reachable from outside.

FastAPI and Starlette on Uvicorn

Uvicorn handles it at the server level. From the Uvicorn settings: --proxy-headers is on by default, but it only trusts connecting IPs in --forwarded-allow-ips, which defaults to 127.0.0.1 and ::1. Recent versions accept networks as well as single addresses.

bash
# Nginx on the same host: the defaults are already right
uvicorn app.main:app --host 127.0.0.1 --port 8000

# Behind a load balancer in 10.0.0.0/16
uvicorn app.main:app --host 0.0.0.0 --forwarded-allow-ips="10.0.0.0/16"

Then request.client.host is the visitor. Don't set --forwarded-allow-ips='*' unless nothing except your proxy can reach the port. * means "anyone may tell me their IP".

Laravel

Laravel 11 and later configure trusted proxies in bootstrap/app.php (docs):

php
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(
        at: ['127.0.0.1', '10.0.0.0/16'],   // your proxies / load balancer subnet
        headers: Request::HEADER_X_FORWARDED_FOR |
                 Request::HEADER_X_FORWARDED_HOST |
                 Request::HEADER_X_FORWARDED_PORT |
                 Request::HEADER_X_FORWARDED_PROTO
    );
})

After that, $request->ip() is the client.

The Laravel docs also suggest trustProxies(at: '*') when you don't know your cloud load balancer's addresses. That's safe only if the application servers accept traffic exclusively from the load balancer, for example through a security group that allows nothing else. Otherwise it trusts any client's header. Prefer the load balancer's subnet. On AWS ELB, the docs say to use Request::HEADER_X_FORWARDED_AWS_ELB for headers.

The docs add a useful reminder: IP addresses should be considered untrusted, user-controlled input, used for informational purposes.

Rails

Use request.remote_ip, not request.ip. remote_ip comes from ActionDispatch::RemoteIp, which skips trusted proxies and raises IpSpoofAttackError when Client-Ip and X-Forwarded-For disagree.

The default trusted list is loopback plus the private and link-local ranges (10/8, 172.16/12, 192.168/16, fc00::/7, …). Proxies with public addresses, such as a CDN, need to be added. Note that setting the option replaces the defaults (API docs), so include them if you still need them:

ruby
# config/application.rb or config/environments/production.rb
config.action_dispatch.trusted_proxies =
  ActionDispatch::RemoteIp::TRUSTED_PROXIES + [IPAddr.new("203.0.113.0/24")] # your edge proxies

Because the defaults trust every private range, a client that can reach the app from inside your network can claim any address. Keep the app port off networks that real clients use.

Go

The standard library gives you r.RemoteAddr (host:port) and nothing else. Popular middleware has had this bug: go-chi's middleware.RealIP used the leftmost X-Forwarded-For entry until the GHSA-9g5q-2w5x-hmxf fix in chi v5.3.0. Upgrade, and check what any "real IP" middleware does before relying on it.

Here's a dependency-free version. net/netip parses addresses, unmaps IPv4-mapped IPv6 and matches prefixes. Go keeps repeated headers separate, so the function collects all of them in order before walking from the right:

go
package clientip

import (
    "net"
    "net/http"
    "net/netip"
    "strings"
)

// The proxies you run. Nothing else may set X-Forwarded-For for you.
var trusted = []netip.Prefix{
    netip.MustParsePrefix("127.0.0.0/8"),
    netip.MustParsePrefix("::1/128"),
    netip.MustParsePrefix("10.0.0.0/8"), // e.g. your load balancers
}

func isTrusted(a netip.Addr) bool {
    for _, p := range trusted {
        if p.Contains(a) {
            return true
        }
    }
    return false
}

func parse(s string) (netip.Addr, bool) {
    a, err := netip.ParseAddr(strings.TrimSpace(s))
    if err != nil {
        return netip.Addr{}, false
    }
    return a.Unmap(), true // ::ffff:203.0.113.7 -> 203.0.113.7
}

// ClientIP returns the client address. X-Forwarded-For is read right to left and
// only while each hop was added by a trusted proxy. Check the result with IsValid().
func ClientIP(r *http.Request) netip.Addr {
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        host = r.RemoteAddr
    }
    ip, ok := parse(host)
    if !ok || !isTrusted(ip) {
        return ip // direct connection: ignore the header
    }
    var hops []string
    for _, v := range r.Header.Values("X-Forwarded-For") { // repeated headers, in order
        hops = append(hops, strings.Split(v, ",")...)
    }
    for i := len(hops) - 1; i >= 0; i-- {
        if strings.TrimSpace(hops[i]) == "" {
            continue
        }
        hop, ok := parse(hops[i])
        if !ok {
            break // malformed entry: keep the last good hop
        }
        ip = hop
        if !isTrusted(ip) {
            break
        }
    }
    return ip
}

Use it as ip := clientip.ClientIP(r) and check ip.IsValid() before you rely on the result. It needs Go 1.18 or later for net/netip.

Plain PHP and Symfony

Without a framework, $_SERVER['REMOTE_ADDR'] is the TCP peer, and $_SERVER['HTTP_X_FORWARDED_FOR'] is the raw header. The same right-to-left rule applies. Don't use the many getRealIp() snippets that check HTTP_CLIENT_IP first, then HTTP_X_FORWARDED_FOR, then REMOTE_ADDR: every one of those headers can be sent by the client.

Symfony configures trusted proxies in config/packages/framework.yaml with trusted_proxies (IPs and CIDRs) and trusted_headers (which X-Forwarded-* headers to honour). After that, $request->getClientIp() is the client. Laravel's trustProxies sits on top of the same Symfony mechanism, so the advice is identical: list your proxies, don't trust everyone.

Find out what's in front of you

When the IP looks wrong and you don't know the topology (a new host, an inherited setup), log the raw inputs for one request before changing anything:

python
# Django: temporary debug view, remove after use
from django.http import JsonResponse

def debug_ip(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"),
        "cf_connecting_ip": request.META.get("HTTP_CF_CONNECTING_IP"),
    })

Read the result like this:

remote_addr looks likeIt meansTrust
127.0.0.1 / ::1a proxy on the same machine (Nginx, Caddy)loopback
172.17.x.x to 172.31.x.x, 10.x.x.xa container network or a load balancer in your VPCthat subnet only
a public address you don't recognise, with your IP at the end of X-Forwarded-Fora CDN or managed load balancerthe provider's published ranges
your own public IP, no forwarding headersno proxy at allnothing: REMOTE_ADDR is already right

Count the entries in X-Forwarded-For while you're at it. With one proxy in front, a normal request has one entry. With a CDN and a load balancer, two. That number is what hop-count settings such as ProxyFix(x_for=n) need, and the addresses tell you what a trusted list should contain.

Mistakes that keep coming back

  • Trusting the header in development and forgetting. trustProxies(at: '*') or --forwarded-allow-ips='*' added while debugging locally and then deployed. Grep your config for * before each release.
  • Correct app config, exposed port. Gunicorn on 0.0.0.0:8000 or a Docker -p 8000:8000 next to a correctly configured Nginx gives attackers a second door where the trust rules don't hold. Bind the app to 127.0.0.1 or a Unix socket, or don't publish the container port.
  • Two layers both "fixing" the IP. Nginx restores the client with realip, and then the app also walks X-Forwarded-For with a hop count that assumed the old topology. Decide which layer resolves the address. The other one should trust exactly that layer.
  • Reading the IP before the middleware runs. In Django, a middleware placed above RealClientIPMiddleware sees the proxy's address. Put it first.

Behind Cloudflare or another CDN

Two layers of proxy, one rule: restore the visitor's address at the first server the CDN connects to (usually Nginx, from CF-Connecting-IP, limited to Cloudflare's ranges), then have the app trust only that server. Real client IP behind Cloudflare has the Nginx config, an auto-update script and a Python helper for apps that face Cloudflare directly.

Quick reference

StackSettingSafe valueSpoofable value
Djangoyour middleware (above)TRUSTED_PROXIES CIDRssplit(",")[0]
Flask / WSGIProxyFix(x_for=n)the exact hop count, app unreachable directlyx_for larger than your hops
Uvicorn--forwarded-allow-ipsyour proxy's IP/CIDR* on a public port
LaraveltrustProxies(at: …)proxy IPs/CIDRs'*' on a reachable server
Railstrusted_proxies + remote_ipdefaults + your edge CIDRsreading X-Forwarded-For yourself
Expresstrust proxy'loopback', CIDRstrue (guide)

Using the IP: country in a Django view

With REMOTE_ADDR corrected, a lookup is straightforward. This helper skips private addresses, caches results in Django's cache and treats every failure as "unknown":

python
import ipaddress
import json
import os
import urllib.error
import urllib.parse
import urllib.request

from django.core.cache import cache


def visitor_country(request):
    """Two-letter country code for request.META["REMOTE_ADDR"], or None."""
    ip = request.META.get("REMOTE_ADDR", "")
    try:
        if not ipaddress.ip_address(ip).is_global:
            return None  # private, loopback, reserved: no public location
    except ValueError:
        return None

    key = f"geo:country:{ip}"
    country = cache.get(key)
    if country is not None:
        return country or None  # "" means "looked up, unknown"

    url = "https://ipapi.gr/api/lookup/" + urllib.parse.quote(ip) + "?" + urllib.parse.urlencode(
        {"key": os.environ["IPAPI_KEY"]}
    )
    try:
        with urllib.request.urlopen(url, timeout=2) as resp:
            data = json.load(resp)
        code = data.get("country_code")
        country = code if code and code != "No Data" else ""
    except (urllib.error.URLError, TimeoutError, ValueError):
        country = ""  # 4xx/5xx, timeout, bad JSON: unknown, retry later
    cache.set(key, country, 6 * 3600 if country else 600)
    return country or None

Fields without data come back as the string "No Data", hence the explicit check. For the full Django and Laravel setups with our API, see the Django integration and Laravel integration guides. For other languages, see get a visitor's country from their IP.

FAQ

Why not just take the last X-Forwarded-For entry?

With exactly one proxy that you control, the last entry is the client, and that's what ProxyFix(x_for=1) does. With a CDN plus a load balancer, the last entry is the CDN's edge. Walking from the right past every trusted address handles any number of hops.

Is REMOTE_ADDR ever spoofable?

Not for an established TCP connection: it's the peer's real address. It's only "wrong" in the sense that it's your proxy. That's why every correct approach starts from it and only looks at headers when it's a known proxy.

Do I need to change anything for IPv6?

Make sure your trusted list includes the proxies' IPv6 addresses (::1 for a local Nginx), and normalize IPv4-mapped addresses (::ffff:a.b.c.d) before comparing. The code above does both.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.