A request-rate limit applies to the identity expressed by its key, not automatically to a person, account, or endpoint. limit_req_zone stores shared state for those key values, and limit_req applies the zone while handling a request. Two locations using the same zone and key can consume the same budget. Choosing a variable therefore defines a real fairness and enforcement boundary.
When it breaks
A client-controlled identifier can turn one apparent limit into many independent allowances. If changing a query value changes the key, an untrusted client can select another budget. An empty key is more direct: nginx does not account for requests whose key evaluates to an empty value. A missing identity variable can therefore silently leave requests outside the intended limit.
An IP key measures an address, which may represent many users behind NAT or an intermediary. Forwarded address headers need an explicit trust policy before they become identity input. Trusting arbitrary X-Forwarded-For lets callers manufacture identities; ignoring a real trusted proxy’s address chain may instead put every user under the proxy’s own budget. Neither outcome is fixed by increasing the rate.
Shared memory scope matters too. A zone is shared among workers of the nginx instance; that does not create a fleet-wide counter across unrelated processes or machines. Compatible graceful reloads can retain zone state. Assuming that every configuration reload resets all allowances can make a test or recovery procedure misread continued rejections as a broken deployment.
Check it quickly
Save the script as check-limit-keys.py and run it with Python, Docker, and the pinned images. The recorded setup uses nginx 1.28.0 with two workers and a disposable HTTP backend. A deliberately slow allowance of one request per minute keeps each observation within the same budget interval; distinct fixture keys isolate the cases:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None,extra_ports=()):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(content if isinstance(content,bytes) else content.encode())
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))]
docker('run','-d','--name',containers[0],'--network',name,*published,tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
request.port=port;request.network=name
request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value}
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args):pass
def do_GET(self):
body=json.dumps({'path':self.path,'method':self.command}).encode()
self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
from concurrent.futures import ThreadPoolExecutor
extra='''
limit_req_zone $arg_client zone=identity:1m rate=1r/m;
limit_req_zone $binary_remote_addr zone=address:1m rate=1r/m;
limit_req_status 429;
add_header X-Limit $limit_req_status always; add_header X-Worker $pid always;
'''
locations='''
location /a { limit_req zone=identity; proxy_pass http://backend:8081; }
location /b { limit_req zone=identity; proxy_pass http://backend:8081; }
location /dry { limit_req zone=identity; limit_req_dry_run on; proxy_pass http://backend:8081; }
location /ip { limit_req zone=address; proxy_pass http://backend:8081; }
location /open { proxy_pass http://backend:8081; }
'''
configuration=config(locations,extra).replace('worker_processes 1;','worker_processes 2;')
with lab(configuration,backend=backend) as (proxy,request):
results={}
assert request('/a?client=one')[0]==200 and request('/b?client=one')[0]==429
assert request('/a?client=two')[0]==200
results['same_zone_cross_location']=[200,429];results['changed_untrusted_key']=200
empty=[request('/a') for _ in range(2)];assert all(r[0]==200 for r in empty)
assert all(r[1].get('x-limit') is None for r in empty)
results['empty_keys']=[{'status':r[0],'limit':r[1].get('x-limit')} for r in empty]
first=request('/ip',headers={'X-Forwarded-For':'192.0.2.1'})
second=request('/ip',headers={'X-Forwarded-For':'192.0.2.2'})
assert first[0]==200 and second[0]==429;results['forged_forwarding_header']=[200,429]
first=request('/dry?client=dry');second=request('/dry?client=dry')
assert first[0]==second[0]==200 and second[1]['x-limit']=='REJECTED_DRY_RUN'
results['dry_run']={'status':second[0],'limit':second[1]['x-limit']}
first=request('/a?client=shared');assert first[0]==200;workers={first[1]['x-worker']}
deadline=time.monotonic()+8
while time.monotonic()<deadline and len(workers)<2:
with ThreadPoolExecutor(max_workers=8) as pool:
replies=list(pool.map(lambda _:request('/b?client=shared'),range(8)))
assert all(r[0]==429 for r in replies);workers.update(r[1]['x-worker'] for r in replies)
assert len(workers)==2,workers
results['shared_budget_worker_ids']=sorted(workers)
docker('exec',proxy,'nginx','-s','reload')
wait(lambda:request('/open')[1]['x-worker'] not in workers)
reply=request('/a?client=shared');assert reply[0]==429 and reply[1]['x-worker'] not in workers
results['graceful_reload']={'status':reply[0],'worker':reply[1]['x-worker']}
print(json.dumps(results,sort_keys=True))
/a?client=one succeeds, while /b?client=one returns 429 because both locations use the same zone. Changing the query identity to two gives a new successful request. This is useful as a controlled demonstration, but it is precisely why an unverified query parameter is unsuitable as the sole production identity for an abuse-resistant limit.
Two requests without the client argument both succeed and have no X-Limit decision value. The empty key was not accounted for. This control is important when a deployment derives identity from an optional header or an authentication result: normal traffic with a populated variable can look correctly limited while missing-variable traffic bypasses the intended accounting.
The IP-based location receives two requests with different fabricated X-Forwarded-For values. The first succeeds and the second returns 429. No real-IP trust configuration is installed, so the socket peer still supplies the address key. This verifies that merely sending a different forwarding header does not alter the configured identity in this setup; it does not authenticate any original internet user.
The dry-run location returns 200 on a request that would have been rejected, with REJECTED_DRY_RUN in the decision header. Accounting still occurs, but enforcement is disabled there. A successful response alone would hide this distinction. Use the dry-run decision variable when assessing a proposed policy instead of counting only client-visible rejections.
For a fresh shared key, the first request succeeds and subsequent requests return 429 from both observed worker process IDs. nginx then performs a graceful reload with the same zone configuration. A new worker serves the still-rejected key, demonstrating retained budget state rather than a reset. The test waits for a new worker identity before attributing the result to the reload.
Define identity, scope, and observation together
Decide whether the intended budget belongs to an address, authenticated account, route, or combination. Verify that its inputs exist and are trustworthy at the limiting phase. Sharing a zone across locations can intentionally create one service-wide budget, while separate zones create distinct accounting. Avoid assuming a location name itself becomes part of a key that never includes it.
Size zone memory for key cardinality as well as request volume. The documentation describes architecture-dependent state sizes; a 64-bit state is not the smaller 32-bit entry assumed by some estimates. Storage exhaustion can evict old state and, if allocation still fails, reject requests. This experiment checks enforcement semantics, not a measured maximum key capacity.
Finally, inspect effective configuration and decision telemetry after changes. Distinguish empty-key bypass, dry-run allowance, actual rejection, and preserved reload state. A working per-instance limiter does not prove a global tenant quota, and a request returning 429 does not by itself identify which intended user or route budget was consumed.
Sources
- nginx request limit module, shared key state, empty keys, dry run, memory sizing, and decision variables, checked 2026-09-12.
- nginx real IP module, trusted proxy boundaries when deriving a client address, checked 2026-09-12.
- RFC 6585 additional HTTP status codes, 429 and identification of the rate-limited subject, checked 2026-09-12.