A rate-limit key defines which requests share a budget. An account, API credential, address, tenant, and authenticated session are different identities, not interchangeable spellings of “client.” Choosing the wrong grouping can punish unrelated users or let one workload obtain fresh allowance simply by changing a field.
Use the identity the policy actually promises
For a per-account rule, derive the key from the account resolved by authentication, not from a caller-supplied account header or query parameter. A credential’s presence is not proof that it is valid. Authentication must verify it and determine the principal before the account-level decision uses that result.
A per-credential budget is legitimate when that is the intended product contract, but it behaves differently from a per-account budget. Rotating a credential or creating another credential can produce a new credential key while retaining the same account. Decide whether that should renew allowance rather than discovering the distinction through abuse or support tickets.
One account is not necessarily one human or one application, and one person can hold several accounts. A stable account key improves consistency of grouping; it does not prove fairness, prevent account farming, or bound the service’s aggregate demand. Additional scopes need their own justified policies.
An address describes a network path
Several accounts behind a corporate NAT, mobile carrier, or VPN can share a public address. An IP bucket combines their traffic. Conversely, a caller that changes addresses can reach a fresh IP bucket. Neither result means the network address was parsed incorrectly; it means IP and principal identity represent different things.
Address-based limits can still protect public or preauthentication endpoints where no verified account exists. Size and evaluate that policy as an address-level control, including legitimate shared-network traffic. Do not describe it as exact per-person enforcement, or assume a single address can catch a scraper distributed across many networks.
Normalize address syntax before using it as a key. Equivalent IPv6 spellings should not create multiple buckets. Whether to aggregate prefixes or equate particular address families is a separate policy choice with collateral-impact tradeoffs; textual normalization does not answer it.
Forwarded identity needs a trust boundary
An arbitrary Forwarded, X-Forwarded-For, or similar header is not proof of client origin. RFC 7239 explicitly warns that the client and intervening nodes can alter forwarding information. Trust depends on the directly connected peer, the configured proxy chain, and whether that chain removes or safely handles user-supplied values.
NGINX’s real-IP module makes trusted source addresses and recursive processing explicit configuration. “Take the first address in the header” is not an adequate general rule. A proxy accepting and retransmitting an untrusted claim can turn a stable-looking key into caller-controlled budget creation.
The fixture below models exactly one trusted proxy that overwrites a field with one IP literal. It ignores replacement claims from other peers and rejects a multi-address value from that proxy. It is not a general forwarding-chain parser or a deployed NGINX configuration.
When it breaks
Omitting tenant scope can merge unrelated accounts that share an identifier. Keying on raw URLs can split the same operation by query strings; use the stable operation identity resolved by routing when the policy is per operation. Ambiguous string concatenation can also collide, so preserve component boundaries in the key encoding.
Avoid using raw bearer secrets as budget keys or diagnostic labels. Stable internal credential or principal identifiers are more appropriate, and logs still need privacy controls. Unbounded identity creation can consume limiter memory; expiration or state eviction must be evaluated alongside the extra allowance that resetting a bucket can create.
Test realistic identities before selecting thresholds: shared addresses, roaming clients, credential rotation, tenants, and forged claims. Also inspect the source of the supposedly verified principal. No choice of dictionary key can repair authentication data that was actually copied from an untrusted request.
Check it quickly
Save the block as identity_check.py and run python3 identity_check.py with Python 3.10 or newer. It ran with Python 3.14.4 and uses only the standard library. The records represent already authenticated and routed fixture requests. They are not an authentication implementation, and the counters model one test window without servers, network traffic, or file writes.
from collections import Counter
from ipaddress import ip_address
def account_key(request):
# These two fields come from authentication and routing, not request headers.
tenant, account = request["verified_actor"]
return ("account", tenant, account, request["operation"])
def admit(counts, key, limit=2):
if counts[key] >= limit:
return False
counts[key] += 1
return True
alice = {"verified_actor": ("north", "alice"), "operation": "export",
"peer": "198.51.100.8", "credential_id": "key-a", "claimed_account": "alice"}
bob = {**alice, "verified_actor": ("north", "bob"), "credential_id": "key-b"}
by_ip, by_account = Counter(), Counter()
assert [admit(by_ip, str(ip_address(request["peer"]))) for request in (alice, alice, bob)] == [
True, True, False,
]
assert [admit(by_account, account_key(request)) for request in (alice, alice, bob)] == [
True, True, True,
]
print("two accounts behind one address collide in the IP budget, but not the account budget")
roaming = {**alice, "peer": "203.0.113.9", "credential_id": "key-a-rotated"}
assert admit(by_ip, str(ip_address(roaming["peer"])))
assert not admit(by_account, account_key(roaming))
for claim in ("bob", "new-account", "admin"):
assert account_key({**roaming, "claimed_account": claim}) == account_key(alice)
print("changing address, credential ID, or an untrusted account claim does not reset this account budget")
other_tenant = {**alice, "verified_actor": ("south", "alice")}
other_operation = {**alice, "operation": "thumbnail"}
assert admit(by_account, account_key(other_tenant))
assert admit(by_account, account_key(other_operation))
assert account_key({**alice, "verified_actor": ("a:b", "c")}) != account_key(
{**alice, "verified_actor": ("a", "b:c")}
)
print("tenant and operation are explicit dimensions; tuple encoding avoids delimiter collisions")
# One configured proxy that overwrites the header with exactly one IP literal.
trusted_proxy = ip_address("192.0.2.10")
def network_key(peer, replacement):
peer = ip_address(peer)
return str(ip_address(replacement)) if peer == trusted_proxy else str(peer)
assert network_key("198.51.100.8", "203.0.113.77") == "198.51.100.8"
assert network_key("198.51.100.8", "not-an-address") == "198.51.100.8"
assert network_key("192.0.2.10", "198.51.100.8") == "198.51.100.8"
assert network_key("2001:0db8:0:0::1", "ignored") == network_key("2001:db8::1", "ignored")
try:
network_key("192.0.2.10", "198.51.100.8, 203.0.113.9")
except ValueError:
pass
else:
raise AssertionError("the single-address proxy contract must reject a chain")
print("untrusted peers cannot choose a forwarded identity; equivalent IPv6 text shares one key")
print("the configured one-address proxy contract rejects a multi-address value")
Alice exhausts an address budget and thereby blocks Bob at the same address, while account grouping leaves Bob’s allowance separate. Alice’s changed address creates new IP allowance, but neither that move, a credential-ID rotation, nor forged account claims reset the account key.
Separate tenants and operations receive separate keys; tuple components avoid delimiter collisions. Untrusted peers cannot choose a replacement address, equivalent IPv6 text normalizes identically, and the single-proxy contract rejects a list of addresses. Authentication, real proxy forwarding, concurrent counting, distributed quotas, and production cardinality are not exercised.
Further reading
Sources
- RFC 6585, section 4 leaves identity and counting scope to the limiter’s policy, checked 2026-09-08.
- RFC 7239, forwarding-header integrity, trusted proxies, and information exposure, checked 2026-09-08.
- NGINX real-IP module, trusted replacement addresses and recursive processing, checked 2026-09-08.