Skip to content

Repository files navigation

Python IPware

Best-effort client IP detection for Python server applications — Django, Flask, or any WSGI/ASGI framework.

status-image version-image coverage-image maintained-image

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)"]
Loading

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
Loading

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_list set: 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.139 yields 177.139.233.139 (v3 returned 10.0.0.1). With leftmost=False the 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), set proxy_count or proxy_list so 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"]
Loading

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 matches 198.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." match 10.1.x.x but not 10.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)"]
Loading

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)"]
Loading

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"]
Loading

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 ⛩️

About

Returns the best matched IP address from a given HTTP(s) header in Python

Resources

Contributing

Security policy

Stars

43 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages