Best-effort client IP detection for Python server applications — Django, Flask, or any WSGI/ASGI framework.
python -m pip install --upgrade python-ipwarefrom python_ipware import IpWare
ipw = IpWare()
# Django: request.META | Flask: request.environ
ip, trusted_route = ipw.get_client_ip(request.META)
if ip:
# ip is an ipaddress.IPv4Address or IPv6Address
ip.is_global # publicly routable
ip.is_private # private network
ip.is_loopback # 127.0.0.1 / ::1
if trusted_route:
# the request came through your configured proxies (proxy_count / proxy_list)
...Python 3.9+ is supported (tested on 3.9 – 3.14), with no upper version cap. No runtime dependencies. On a newer Python that isn't in the test matrix yet? It should just work — if it doesn't, open an issue and we'll fix it.
Legacy: the frozen 3.x algorithm is still available with
IpWare(algorithm="legacy"). See the legacy guide.
flowchart LR
R["Incoming request"] --> I["IpWare().get_client_ip(...)"]
I --> RL["Rate limiting and throttling"]
I --> GEO["Geo-location and localization"]
I --> LOG["Audit and access logs"]
I --> FR["Abuse and fraud signals<br/>(check trusted_route)"]
I --> AUTH["Login anomaly checks<br/>(check trusted_route)"]
Found a security issue? Please email info@neekware.com privately — do not open a public issue or pull request. See SECURITY.md.
There is no perfect defense against IP address spoofing. Headers such as X-Forwarded-For are set by
clients and proxies, and can be forged. If you use python-ipware for authentication, rate limiting, or
anti-fraud, configure proxy_count and/or proxy_list for your network topology and treat it as one
layer alongside your firewall — never as the only defense.
sequenceDiagram
participant A as Attacker (real IP 8.8.8.8)
participant P as Your proxy chain
participant App as Your app
A->>P: X-Forwarded-For: 1.2.3.4 (forged)
P->>App: X-Forwarded-For: 1.2.3.4, 8.8.8.8, 104.16.0.1, 34.120.0.1
Note over App: IpWare() trusts the left-most entry and returns 1.2.3.4 (spoofed)
Note over App: IpWare(proxy_count=2) counts from the right and returns 8.8.8.8
Note over App: Adding strict=True rejects the tampered header entirely
IpWare(
precedence=None, # tuple of header keys to check, in order
leftmost=True, # client is the left-most IP in the chain
proxy_count=None, # expected number of proxies in front of your server
proxy_list=None, # trusted proxies: CIDR networks or IP prefixes
)
ip, trusted_route = ipw.get_client_ip(meta, strict=False)| Parameter | Description |
|---|---|
precedence |
Header keys to search, top to bottom. Defaults to the list below. |
leftmost |
True (default) follows the de-facto client, proxy1, proxy2 order. Use False only for networks that put the client right-most. |
proxy_count |
Number of proxies expected after the client. 0 is valid; None disables the check. |
proxy_list |
Trusted proxies nearest your server, one entry per hop. Each entry is a CIDR network ("100.64.0.0/10", "fd7a:115c:a1e0::/48"), a complete IP matched exactly ("198.84.193.157"), or an IP prefix matched on whole octets ("10.1."). See Trusted proxies. |
strict |
False: at least proxy_count / proxy_list proxies. True: exactly that many — extra or invalid entries reject the header. |
| Output | Description |
|---|---|
ip |
IPv4Address, IPv6Address, or None |
trusted_route |
True when proxy_count or proxy_list was configured and matched, for any returned IP (v3 only set it for public IPs) |
Headers are checked in precedence order. Every address is ranked:
| Rank | Addresses |
|---|---|
| 1. public | globally routable |
| 2. private | RFC 1918, IPv6 ULA, CGNAT 100.64.0.0/10, documentation ranges |
| 3. link-local | 169.254.0.0/16, fe80::/10 |
| 4. loopback | 127.0.0.0/8, ::1 |
| never returned | 0.0.0.0, ::, multicast, broadcast, reserved |
The first public IP wins. If none is found, the best-ranked IP wins, and the earlier header wins a tie.
Within one header, the client entry depends on your proxy settings:
proxy_count/proxy_listset: the entry just before your trusted proxies, exactly as in v3.- Neither set: the first public entry in the chain, not only the first entry. So
10.0.0.1, 177.139.233.139yields177.139.233.139(v3 returned10.0.0.1). Withleftmost=Falsethe chain is scanned from the right. That public entry may be an upstream proxy rather than the client. If you need to identify private clients (intranet, VPN), setproxy_countorproxy_listso the client position is fixed.
Every default header can be sent by a client, and the modern engine now also reads Forwarded,
which sits above the CDN headers. Behind a CDN, pass an explicit precedence naming that CDN's
header (see below).
flowchart TD
A["Request headers (...)"] --> B["Take the next header in precedence order"]
B --> C{"Header present?"}
C -->|no| B
C -->|yes| D["Split the chain: client, proxy1, proxy2"]
D --> E{"Matches proxy_count and proxy_list?"}
E -->|no| B
E -->|yes| F["Pick the client entry"]
F --> G{"Public IP?"}
G -->|yes| H["Return (ip, trusted_route)"]
G -->|no| I["Keep if it outranks the current fallback"]
I --> B
B -->|no headers left| J["Return the best fallback, else None"]
The legacy engine keeps v3's rules. A combination suite checks that the modern engine never returns a worse address than legacy for the same input.
Ports are stripped (1.2.3.4:8080, [2001:db8::1]:443). IPv4-mapped (::ffff:1.2.3.4) and NAT64
well-known-prefix (64:ff9b::1.2.3.4) addresses are returned as plain IPv4. RFC 7239 Forwarded elements are read by their
for= value (for="[2001:db8::1]:4711";proto=https). Malformed tokens such as [::1,
[::1]junk, or 1.2.3.4:abc are rejected rather than truncated. Header names match
case-insensitively, so lowercase keys (AWS Lambda / API Gateway v2) work too.
(
"X_FORWARDED_FOR", # load balancers / proxies (AWS ELB, etc.)
"HTTP_X_FORWARDED_FOR",
"HTTP_CLIENT_IP", # Amazon EC2, Heroku
"HTTP_X_REAL_IP",
"HTTP_X_FORWARDED", # Squid
"HTTP_X_CLUSTER_CLIENT_IP", # Rackspace LB, Riverbed Stingray
"HTTP_FORWARDED_FOR", # de facto variant
"HTTP_FORWARDED", # RFC 7239
"HTTP_CF_CONNECTING_IP", # Cloudflare
"HTTP_TRUE_CLIENT_IP", # Cloudflare Enterprise, Akamai
"HTTP_FASTLY_CLIENT_IP", # Fastly, Firebase
"HTTP_FLY_CLIENT_IP", # Fly.io
"HTTP_X_APPENGINE_USER_IP", # Google App Engine
"X-CLIENT-IP", # Microsoft Azure
"X-REAL-IP", # NGINX
"X-CLUSTER-CLIENT-IP", # Rackspace Cloud Load Balancers
"X_FORWARDED",
"FORWARDED_FOR",
"CF-CONNECTING-IP",
"TRUE-CLIENT-IP",
"FASTLY-CLIENT-IP",
"FLY-CLIENT-IP",
"FORWARDED",
"CLIENT-IP",
# added after 4.0.0 — always below every earlier entry, above REMOTE_ADDR
"HTTP_X_CLIENT_IP", # Microsoft Azure (Django/WSGI form)
"X-APPENGINE-USER-IP", # Google App Engine (raw form)
"HTTP_X_AZURE_CLIENTIP", # Azure Front Door
"X-AZURE-CLIENTIP",
"HTTP_DO_CONNECTING_IP", # DigitalOcean App Platform
"DO-CONNECTING-IP",
"HTTP_X_ENVOY_EXTERNAL_ADDRESS", # Envoy / Istio
"X-ENVOY-EXTERNAL-ADDRESS",
"REMOTE_ADDR", # direct connection
)Headers released earlier never move. New ones are added only just above REMOTE_ADDR, so an
upgrade can never let a new header outrank one that already resolved your requests.
Narrow it to what your infrastructure actually sets:
ipw = IpWare(precedence=("HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"))If all your traffic comes through a CDN, put its header first. Only do this when the app is not reachable directly, because clients can send these headers themselves:
# Behind Cloudflare only
ipw = IpWare(precedence=("HTTP_CF_CONNECTING_IP", "HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"))If your server sits behind known proxies, pass their IPs or prefixes:
Each entry can be (modern engine):
- a complete IP, matched exactly:
"198.84.193.157"never matches198.84.193.15x, and IPv6 spelling (case, leading zeros) does not matter; - a CIDR network (IPv4 or IPv6), matched by membership. This is the recommended form for IPv6;
- an IP prefix, matched on whole octets or groups:
"10.1"and"10.1."match10.1.x.xbut not10.100.x.x. IPv6 prefixes compare against the compressed form (2001:db8::5), so prefer CIDR.
IPv4-mapped (::ffff:a.b.c.d) and NAT64 (64:ff9b::a.b.c.d) hops are unwrapped to IPv4 before matching.
Misconfiguration raises ValueError at construction: a bare string instead of a list, empty or
non-IP entries, an invalid CIDR, or a negative or non-integer proxy_count. proxy_count and
proxy_list may both be set: the list fixes the client position, and the count is a hop-count
requirement (a minimum, or exact when strict=True).
ipw = IpWare(proxy_list=["198.84.193.157"]) # one proxy
ipw = IpWare(proxy_list=["198.84.193.157", "198.84.193.158"]) # two proxies
ipw = IpWare(proxy_list=["177.139.", "177.140"]) # prefixes for dynamic IPs
ipw = IpWare(proxy_list=["100.64.0.0/10"]) # CIDR network (IPv4 or IPv6)
# non-strict — X-Forwarded-For: <fake>, <client>, <proxy1>, <proxy2>
ip, trusted_route = ipw.get_client_ip(request.META)
# strict — X-Forwarded-For must be exactly: <client>, <proxy1>, <proxy2>
ip, trusted_route = ipw.get_client_ip(request.META, strict=True)flowchart LR
RC["Real client<br/>8.8.8.8"] --> LB["Trusted proxy<br/>198.84.193.157"]
LB -->|"XFF: 8.8.8.8, 198.84.193.157"| APP["Your app<br/>proxy_list: 198.84.193.157"]
FC["Fake client<br/>5.6.7.8"] -->|"bypasses the proxy<br/>XFF: 1.2.3.4 (forged)"| APP
APP --> OK["Real request: (8.8.8.8, True)"]
APP --> NO["Fake request: (None, False)"]
If you know how many proxies are in front of you but not their IPs (for example, across providers):
ipw = IpWare(proxy_count=2)
# non-strict — at least 2 proxies
ip, trusted_route = ipw.get_client_ip(request.META)
# strict — exactly 2 proxies: <client>, <proxy1>, <proxy2>
ip, trusted_route = ipw.get_client_ip(request.META, strict=True)flowchart LR
C["Client<br/>8.8.8.8"] --> P1["Proxy 1<br/>104.16.0.1"] --> P2["Proxy 2<br/>34.120.0.1"] --> APP["Your app<br/>proxy_count=2"]
APP --> H1["XFF: 8.8.8.8, 104.16.0.1, 34.120.0.1<br/>returns (8.8.8.8, True)"]
APP --> H2["XFF: 1.2.3.4, 8.8.8.8, 104.16.0.1, 34.120.0.1<br/>forged prefix ignored: (8.8.8.8, True)<br/>strict=True: (None, False)"]
Combine both for the tightest check:
ipw = IpWare(proxy_count=1, proxy_list=["198.84.193.157"])The de-facto standard puts the originating client left-most. For the rare network that puts it right-most:
ipw = IpWare(leftmost=False)flowchart LR
S["Standard: client, proxy1, proxy2"] -->|"leftmost=True (default)"| A["client = first entry"]
R["Reversed: proxy2, proxy1, client"] -->|"leftmost=False"| B["client = last entry"]
See docs/nginx.md for an NGINX configuration example.
python -m pip install -e '.[dev]'
ruff check .
python -m unittest discover -s tests -p "tests_*.py" # full suite
python -m tests.legacy.run_against_legacy # v3 suite against the legacy engine
python -m build && python -m twine check dist/*Released under the MIT license.
python-ipware is actively maintained with Dojo ⛩️. The legacy engine is frozen for
backward compatibility; all improvements target the modern engine. Need support? Reach
Neekware Inc. at info@neekware.com.
Neekware Inc. — creator of Dojo Workspace, your AI workspace for building, learning, and getting things done.
🚀 Created with Dojo ⛩️