skip to content

Behind a reverse proxy, a DRF search endpoint's AnonRateThrottle either lumps all clients into one bucket or is dodged with forged X-Forwarded-For headers — what does NUM_PROXIES change?

level: seniorimportance: should knowfreq 38%

answer

  1. which address identifies the client
  2. REMOTE_ADDR is the last hop
  3. the default trusts the whole header
  4. count trusted hops from the right

basics

~20 s

DRF identifies anonymous clients in BaseThrottle.get_ident(). With NUM_PROXIES unset it uses the whole X-Forwarded-For string, which clients can vary; set it to your proxy count so DRF takes the address that many entries from the right.

solid answer

~40 s

Anonymous throttling keys on `BaseThrottle.get_ident()`. With `NUM_PROXIES` at its default `None`, DRF uses the **entire** `X-Forwarded-For` value, whitespace removed, when the header exists, and `REMOTE_ADDR` otherwise. That gives both failures: if the proxy does not forward the header, `REMOTE_ADDR` is the proxy, so every client shares one bucket; if it does, a client can prepend a random fake address each time and get a fresh key per request. Setting `NUM_PROXIES = n` makes DRF split the header and take the `n`-th entry from the right, which is the address your own outermost proxy saw; `NUM_PROXIES = 0` always uses `REMOTE_ADDR`. The count must match your real proxy chain: too high picks a client-supplied entry, too low picks a proxy address. Clients behind one NAT gateway still share a key.

code

python · 9 lines
python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {"anon": "60/min"},
    # Edge proxy + load balancer, each appending to X-Forwarded-For.
    "NUM_PROXIES": 2,
}

go deeper

for a junior

Know that DRF identifies anonymous clients by IP and that a proxy changes which address Django sees.

for a middle

Explain get_ident(): the None default using the whole header, 0 meaning REMOTE_ADDR, and n meaning the n-th entry from the right.

for a senior

Diagnose a shared bucket or a bypassed limit from logs, set NUM_PROXIES to the real chain, and close direct access to the app port.

for a principal

Decide which limits belong at the edge and which in DRF, and who owns keeping the proxy count correct as infrastructure changes.

## How DRF names an anonymous client Django REST Framework (DRF) throttles anonymous requests by client address. `AnonRateThrottle`, and `UserRateThrottle` or `ScopedRateThrottle` for anonymous callers, all build their cache key from `BaseThrottle.get_ident(request)`. Two request values feed it: - `REMOTE_ADDR` — the address of whatever opened the TCP connection to the application server. Behind a reverse proxy, that is the proxy. - `X-Forwarded-For` (XFF) — a comma-separated list that proxies extend with the address they received the request from. Its left-hand entries come from whoever sent the request first, **including the client itself**, so they can be forged. ## The default: `NUM_PROXIES = None` `rest_framework/settings.py` ships `NUM_PROXIES: None`. With that value `get_ident()`: 1. returns the **whole** XFF string with whitespace removed if the header is present; 2. otherwise returns `REMOTE_ADDR`. Behind a proxy this fails in one of two ways, and which one depends on the proxy: | Proxy behaviour | Identifier DRF uses | Result | |---|---|---| | does not add XFF | the proxy's address | **every** anonymous client shares one bucket; one scraper locks out everyone | | adds XFF | the full header text | a client sending `X-Forwarded-For: <random>` gets a new key per request and is never throttled | The second case is the dangerous one for a public search endpoint: the limit looks configured and simply does not bind the clients it was meant for. ## Setting `NUM_PROXIES` `NUM_PROXIES` is the number of proxies you operate in front of Django. DRF then trusts only the entries those proxies wrote: - `NUM_PROXIES = 0` — ignore XFF and always use `REMOTE_ADDR` (right when Django is reached directly). - `NUM_PROXIES = n > 0` — split XFF on commas and take `addrs[-min(n, len(addrs))]`, the `n`-th entry from the right, stripped of spaces. Worked example with two proxies, an edge proxy and a load balancer, each appending the address it received the connection from: 1. The client sends `X-Forwarded-For: 203.0.113.99` (forged). 2. The edge proxy appends the real client: `203.0.113.99, 198.51.100.7`. 3. The load balancer appends the edge proxy: `203.0.113.99, 198.51.100.7, 10.0.0.5`. 4. With `NUM_PROXIES = 2`, DRF takes the second from the right: `198.51.100.7` — the real client. ## Getting the count wrong - **Too high** — DRF reads further left, into entries the client wrote, and forged headers work again. - **Too low** — DRF reads one of your own proxies' addresses, and many clients collapse into one bucket. - **Requests that bypass the proxies** — a shorter header makes DRF fall back to the leftmost entry, so the application port should not be reachable except through the proxy chain. - **NAT** — the DRF docs note that all clients behind one NAT gateway are treated as a single client once `NUM_PROXIES` is set; an office or a mobile carrier can share a key. ## Operating advice - Set `NUM_PROXIES` explicitly in every deployed environment; the default is only correct when there is no proxy and no XFF. - Log `get_ident()` for a few requests after each infrastructure change and check it against a known client address. - For authenticated traffic prefer `UserRateThrottle`, which keys on `request.user.pk` and is immune to address games. - Treat address-based throttling as a fairness control. The DRF documentation states that malicious actors can always spoof IP origins, so abuse protection belongs in front of Django as well.

  • Why doesn't setting NUM_PROXIES help DRF's UserRateThrottle for logged-in users?
    For authenticated requests `UserRateThrottle` keys on `request.user.pk`, not on an address, so proxies and forged headers do not affect it. `NUM_PROXIES` only matters for the anonymous branch of the user and scoped throttles and for `AnonRateThrottle`.
  • In DRF, what does NUM_PROXIES = 0 do when requests arrive with an X-Forwarded-For header?
    `get_ident()` returns `REMOTE_ADDR` whenever `NUM_PROXIES` is 0, ignoring the header entirely. That is correct when Django is reached directly, and wrong behind a proxy, where `REMOTE_ADDR` is the proxy and all clients share one bucket.

saying these in an interview costs you the question

  • DRF ignores X-Forwarded-For unless NUM_PROXIES is set.
  • With NUM_PROXIES unset, DRF uses the leftmost X-Forwarded-For address.
  • A larger NUM_PROXIES is always the safer choice.
  • REMOTE_ADDR is the real client address behind a load balancer.
  • Once NUM_PROXIES is right, IP throttling stops determined attackers.