Python IPware
Best-effort client IP detection for Python server applications — Django, Flask, or any WSGI/ASGI framework.
Quickstart
python -m pip install --upgrade python-ipware
from 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.
What it's used for
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)"]
Security notice
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
API
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) |
Selection rules
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.
Default header precedence
(
"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"))
Trusted proxies
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)"]
Proxy count
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"])
Right-most client networks
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.
Development
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/*
License
Released under the MIT license.
Maintenance
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.
Sponsors
Neekware Inc. — creator of Dojo Workspace, your AI workspace for building, learning, and getting things done.
🚀 Created with Dojo ⛩️
Metadata
Release files for python-ipware 4.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| python_ipware-4.1.1.tar.gz | 40.6 kB | Without provenance | Details for python_ipware-4.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| python_ipware-4.1.1-py3-none-any.whl | Python 3 | none | any | Details for python_ipware-4.1.1-py3-none-any.whl |
Total release size: 68.2 kB
Release files / python_ipware-4.1.1.tar.gz
| Download URL | python_ipware-4.1.1.tar.gz |
|---|---|
| Size | 40.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fd6102058e74c29112b4376f8abbae718852c95f8de6965801de88ca9780d22f
|
|
BLAKE2b-256 checksum How to use checksums |
801cdca9478d5e2c51c5effcc8fbc9903c40c59a8f107d2ef5ea2e4cf53085f0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|
Release files / python_ipware-4.1.1-py3-none-any.whl
| Download URL | python_ipware-4.1.1-py3-none-any.whl |
|---|---|
| Size | 27.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
52eedd4cb7bb9e1bb6b9dd92db974e729fe54be22e1389703e21c60344b9f039
|
|
BLAKE2b-256 checksum How to use checksums |
fb634c5c354d10ec19d6258acc04cb8bbc36ba670019e2ad9c60e8289af9e845
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.9
|
Security for python-ipware 4.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.