# django-ipware ranks the addresses in your proxy headers and returns the one that counts

> django-ipware is a small Django package that turns the pile of client address headers in request.META into a single ranked answer. It parses the chain, validates it against the proxies you say you trust, and prefers a public address over a private, link-local or loopback one, with strict chain validation on by default and a frozen 7.x algorithm available for comparison. The README is blunt that this is one layer of defence and never the only one.

**un33k/django-ipware** — A Django application to retrieve client's IP address

- Repository: https://github.com/un33k/django-ipware
- Stars: 1,063 · Forks: 97
- Language: Python
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/un33k-django-ipware

## The problem is that request.META is a list of claims

The one line summary in the README is the whole proposition: messy, forgeable request headers in, one clean, ranked client IP out.

What arrives in request.META is a pile. There is X-Forwarded-For, which may hold a comma separated chain with ports attached. There is the standard Forwarded header with quoted addresses and brackets. There is CF-Connecting-IP and X-Real-IP, and the diagram points at 30 or more CDN and proxy headers. And there is REMOTE_ADDR, which is the one your web server sets and the only one that is hard to forge directly.

Picking the right string out of that is harder than it looks, and the README's own diagrams say why. The pipeline strips ports and brackets, unwraps IPv4-mapped and NAT64 addresses and rejects malformed values, then validates the chain against your trusted proxies and your expected proxy count, then ranks what is left.

The result is a tuple, a string and a flag.

```python
from ipware import get_client_ip

client_ip, is_routable = get_client_ip(request)

if client_ip is None:
    ...  # no usable IP in the request
elif is_routable:
    ...  # a public internet address
else:
    ...  # private, link-local or loopback (intranet, VPN, local dev)
```

The value is that the address comes with a statement about what kind of address it is, which is what you need for the use cases the README lists, namely rate limiting and throttling, geo-location and localization, audit and access logs, abuse and fraud signals with configured trusted proxies, and login anomaly checks, again with configured trusted proxies.

## The configuration that matters is the proxy list

The API is short, and two of its arguments carry almost all of the meaning.

```python
get_client_ip(
    request,
    proxy_order="left-most",     # or "right-most"
    proxy_count=None,            # expected number of proxies in front of Django
    proxy_trusted_ips=None,      # trusted proxies: IPs, CIDR networks or prefixes
    request_header_order=None,   # header keys to check, in order
    strict=None,                 # strict chain validation; default True
    algorithm=None,              # default "modern"; "legacy" for exact 7.x results
)
```

proxy_count is the number of proxies expected after the client, with 0 being valid and None disabling the check. proxy_trusted_ips is the list of trusted proxies nearest Django, one entry per hop, and each entry may be a CIDR network, a complete IP matched exactly, or an IP prefix matched on whole octets.

strict is where the security lives. With strict set to True, which is the default, the chain must contain exactly proxy_count proxies and any malformed entry rejects that header. With strict set to False, at least that many proxies are required and bad entries are skipped instead of rejecting the header.

The difference between those two modes is the difference between a wrong answer and no answer, and the sequence diagram in the README shows the failure it is designed to catch. An attacker with a real address of 8.8.8.8 forges an X-Forwarded-For of 1.2.3.4, and with proxy_count=2 and strict=False the library counts from the right and returns 8.8.8.8, while with the default strict mode the tampered header is rejected.

The precedence rules are also explicit. Call arguments always win over settings, and settings win over defaults, with the settings named IPWARE_ALGORITHM, IPWARE_META_PRECEDENCE_ORDER, IPWARE_META_PROXY_COUNT and IPWARE_STRICT. An invalid value raises ValueError, whether it is an unknown proxy_order or algorithm, a trusted proxy list passed as a bare string, an empty or non-IP entry, or a negative proxy_count.

## The ranking table decides which address you get

The selection rules are given as a table, and the rule at the end is the one that produces a surprising result in a messy header.

Headers are checked in precedence order, and every address inside them is ranked. Rank one is a public address, globally routable, and is_routable is True. Rank two is private, covering RFC 1918, IPv6 unique local addresses, CGNAT at 100.64.0.0/10 and documentation ranges. Rank three is link-local, 169.254.0.0/16 and fe80::/10. Rank four is loopback, 127.0.0.0/8 and ::1. And there is a category of addresses that is never returned at all, namely 0.0.0.0, ::, multicast, broadcast and reserved addresses.

The rule that follows is that the first public IP wins, and if none is found the best ranked IP wins, with the earlier header winning a tie.

That last clause is the one to understand. A header containing a private address, which is what you get on an internal network, does not cause the function to give up and return the private address from that header straight away. It keeps looking through the rest of the precedence list for a public address, and only when there is none anywhere does the best ranked candidate win. In a request that passed through a CDN, the CDN's own address is usually the public one at the end of the chain and the private one is your proxy, so the ranking is what keeps the proxy from being reported as the client.

It also means the output is deliberately conservative in a second way. An address in the never returned category is discarded even if it is the only candidate, which is why client_ip can be None, and the README says there is no usable IP in the request in that case.

## Version 8 pairs a modern engine with a frozen 7.x one

The 8.0 release is the reason this package has two engines, and the mechanism is in the dependency rather than in this repository.

The package depends on python-ipware at 4.1.0 or newer and below 5, with a comment in the project metadata explaining that 4.1 is the first release with the modern and legacy engine router this package relies on. The default is the modern engine, and the legacy path runs the frozen 7.x function, which is selected either with the algorithm argument or with the IPWARE_ALGORITHM setting set to legacy.

That pairing exists for a compatibility reason rather than a feature reason. The README notes the frozen 7.x behaviour is still available, and the argument table describes the legacy option as producing exact 7.x results. If you upgraded from 7.x and your tests or your logs started producing different addresses, the legacy algorithm gives you the old answers while you work out why the new ones differ.

The new engine is also where the strict chain validation lives, and the strict default is a behaviour change from the 7.x line, which is why the legacy switch and the RELEASE_NOTES_v8.0.0.md file in the repository both exist.

The engine itself is not part of this repository, and the README says so, pointing at python-ipware and noting that it can be used directly in Django, Flask, FastAPI or any WSGI or ASGI application, and that it also returns a trusted_route flag and ipaddress objects. So a non-Django project that needs the same parsing and ranking has the same engine available without the Django wrapper.

## The README says plainly that this is not a firewall

The security section is the part of the documentation to read before shipping, and it does not oversell anything.

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 django-ipware for authentication, rate limiting or anti-fraud, the instruction is to configure proxy_trusted_ips and or proxy_count for your network topology and to treat it as one layer alongside your firewall, never as the only defense.

That is a direct statement of the failure mode. The library can tell you that the chain does not look right, and the strict mode rejects a tampered header outright, but it cannot know your topology. A deployment behind a CDN with an unknown number of hops, or one where the CDN adds and removes hops depending on the route, will produce a wrong answer whatever you configure, and the only defence against that is a chain you actually control.

The other security note is about how to report a problem in this package. If you have found a security issue, the instruction is to email info@neekware.com privately and not to open a public issue or pull request, and the repository carries a SECURITY.md with the details. That is the standard arrangement for a package that parses untrusted input, and it is worth noting that the parsing step is where a vulnerability would live.

The project itself is MIT licensed, the package is marked as typed with a Typing :: Typed classifier, and it supports Python 3.10 through 3.14 with Django 5.2, 6.0 and 6.1. The release is v8.0.0 from 2026-09-22, the last push was on the same day, and the project is not archived. On older Python or Django versions the README tells you to use django-ipware below version 8, which is a clean way to keep an old deployment on the old behaviour.

## Conclusion

django-ipware fits a Django application behind one or more known proxies that needs a client address for rate limiting, audit logs or abuse signals, and that can state how many proxies are in front of it. It is the wrong tool for a deployment with an unknown proxy topology, because the trusted proxy list is the configuration that makes the result meaningful. Verify first how many proxies sit in front of your application and whether their addresses are stable, because proxy_count and proxy_trusted_ips are the two settings that decide whether the returned address is the client or an attacker.

## FAQ

### How do I install django-ipware?

With python -m pip install --upgrade django-ipware. The package requires Python 3.10 or newer and Django 5.2 or newer, and on older versions of either you should use django-ipware below version 8.

### What does get_client_ip return?

A tuple of the client IP as a string or None, and is_routable, which is True when the address is publicly routable. A None result means there was no usable IP in the request.

### What do proxy_count and proxy_trusted_ips do?

proxy_count is the number of proxies expected after the client, with 0 valid and None disabling the check. proxy_trusted_ips is the list of trusted proxies nearest Django, one per hop, given as a CIDR network, a complete IP matched exactly, or an IP prefix matched on whole octets.

### What does the strict option change?

With strict set to True, which is the default, the chain must contain exactly proxy_count proxies and any malformed entry rejects that header. With strict set to False, at least that many proxies are required and bad entries are skipped, while the unconfigured call in the README's sequence diagram is the one that returns the forged address.

### Can I use django-ipware outside Django?

Yes, the README says django-ipware is powered by python-ipware and that you can use it directly in Django, Flask, FastAPI or any WSGI or ASGI app, and that the library also returns a trusted_route flag and ipaddress objects. The current release is v8.0.0 from 2026-09-22 and the licence is MIT.

## Sources

- [Issues](https://github.com/un33k/django-ipware/issues)
- [License: MIT](https://github.com/un33k/django-ipware/blob/master/LICENSE)
- [README](https://github.com/un33k/django-ipware/blob/master/README.md)
- [Releases](https://github.com/un33k/django-ipware/releases)
- [un33k/django-ipware on GitHub](https://github.com/un33k/django-ipware)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/un33k-django-ipware
