Why does Django REST Framework's own documentation say its throttling is not a security control, and how does the configured cache backend affect the limits it enforces?
answer
- where the request history lives
- Django's default cache is per process
- read, modify, write without a lock
- addresses can be spoofed
basics
~20 sDRF stores each client's request timestamps in Django's cache with a non-atomic read and write, so concurrent requests slip through, a per-process LocMemCache multiplies the limit by the worker count, and IP identities can be spoofed.
solid answer
~50 s`SimpleRateThrottle` keeps a list of timestamps per client key and on each request does `cache.get()`, trims old entries, checks the length and `cache.set()`s the list back. That read-modify-write is not atomic, so concurrent requests can all read the same history and all pass — the DRF docs call the result "fuzziness". The throttles use Django's `default` cache unless a subclass sets `cache = caches["..."]`, and Django's default `CACHES` is `LocMemCache`, which lives inside each process: with eight workers a `20/min` limit becomes roughly `160/min`, and a restart forgets everything. A dummy cache stores nothing, so nothing is ever throttled. Add IP spoofing for anonymous keys, and the docs' conclusion follows: DRF throttling is fair-use shaping, not brute-force or denial-of-service protection. Use a shared cache for counts that must hold across workers, and put abuse controls in front of Django.
code
python · 10 lines# settings.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
"throttle": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://cache.internal:6379/1",
},
}go deeper
Recall that DRF stores throttle counts in Django's cache and that its docs say throttling is not a security measure.
Explain the timestamp history, the non-atomic get and set, and why LocMemCache gives each process its own count.
Size the real limit under your worker count, move throttle keys to a shared cache alias, and isolate throttle state in tests.
Split responsibilities between fairness limits in DRF and abuse limits at the edge, and decide which quotas justify atomic counters.
## Where DRF keeps the count Django REST Framework (DRF) implements `AnonRateThrottle`, `UserRateThrottle` and `ScopedRateThrottle` on top of `SimpleRateThrottle`. For each request it: 1. builds a key such as `throttle_search_42` from the scope and the client's identity; 2. reads the stored **history** — a list of request timestamps — with `self.cache.get(key, [])`; 3. drops timestamps older than the window; 4. refuses if the list is already as long as the allowed number of requests; 5. otherwise inserts the current timestamp and writes the list back with `self.cache.set(key, history, duration)`. `self.cache` is `django.core.cache.cache`, the project's **`default`** cache alias, unless a subclass overrides the `cache` attribute. Everything therefore depends on what that cache is. ## What the backend does to the limit | Cache configured as `default` | Effect on throttling | |---|---| | `LocMemCache` (Django's default `CACHES`) | one history per **process**; the effective limit is multiplied by the number of worker processes, and a restart clears it | | `DummyCache` | `get()` always returns the default and `set()` stores nothing, so every request is allowed | | a shared networked cache backend | one history for all workers; the intended limit, still subject to races | | a cache that evicts under memory pressure | an evicted key silently resets that client's count | The DRF throttling guide says the local-memory default "should be okay for simple setups" — a single process, a hobby deployment. For a public endpoint served by several worker processes or machines, the count must live in a cache all of them share. A dedicated alias keeps throttle keys away from content caches: ```python from django.core.cache import caches from rest_framework.throttling import AnonRateThrottle class SearchAnonThrottle(AnonRateThrottle): cache = caches["throttle"] ``` ## Why the check is racy Steps 2 to 5 are separate cache calls with no lock. Two requests from the same client arriving together can both read a history of 19 entries under a `20/min` limit, both pass, and both write back 20 entries — one of them overwriting the other's timestamp. The DRF docs state the built-in throttles use non-atomic operations and are open to race conditions, so under high concurrency a few extra requests get through. For fair-use limits that is acceptable; for a hard quota it is not. ## Why it is not a security control The DRF guide says, in bold, that its throttling "should not be considered a security measure or protection against brute forcing or denial-of-service attacks". The reasons stack up: - **Identity is weak for anonymous clients.** Keys come from IP addresses, which attackers rotate or spoof, and a misconfigured `NUM_PROXIES` makes forging trivial. - **Counts are approximate.** Races and per-process caches let more through than configured. - **The work is already done.** A throttled request has passed through the web server, Django's middleware, authentication and the permission check before `check_throttles()` refuses it, so a flood still consumes application capacity. - **State can vanish.** Restarts, evictions or a cache outage reset histories. ## What to do instead, and what DRF throttling is good for - Use DRF throttles for **per-client fairness**: stopping one integration or scraper from monopolising the search endpoint, with a polite 429 and `Retry-After`. - Put connection and request-rate limits for abuse at the edge — the reverse proxy or a gateway — where refusing is cheap. - For login and password endpoints, combine throttling with account-level lockout or back-off rather than relying on an IP count. - In tests, clear the cache between cases; throttle histories otherwise leak from one test into the next.
- Why might override_settings on DEFAULT_THROTTLE_RATES not change the rate in a DRF test?`SimpleRateThrottle.THROTTLE_RATES` is bound to `api_settings.DEFAULT_THROTTLE_RATES` when the class is defined, at import. DRF reloads `api_settings` on `setting_changed`, but the class attribute still points at the old dictionary. Tests usually patch the throttle class's `rate` or `THROTTLE_RATES` instead, and clear the cache between cases.
- Does moving DRF's throttle cache to a shared backend remove the race?No. It fixes the per-process split, so all workers see one history, but the get-trim-set sequence is still three separate operations. Concurrent requests can still read the same history and all pass. A strict quota needs an atomic counter, which means a custom throttle or a limit enforced outside DRF.
LocMemCache throttling is like giving each ticket gate at a stadium its own tally sheet: a fan limited to three entries can walk through three at every gate, because no gate knows what the others counted.
saying these in an interview costs you the question
- DRF throttling reliably blocks brute-force login attempts.
- Throttle counts are shared across workers whatever cache is configured.
- A shared cache backend makes DRF throttling exact.
- A throttled request never reaches Django, so floods cost nothing.
- DRF keeps throttle state in the database by default.