限流键定义了哪些请求共用一份预算。账号、API 凭据、地址、租户和已认证会话是不同身份,不是“客户端”的几种可互换写法。选错归组方式,可能让无关用户相互挤占额度,也可能让同一份工作只改一个字段,就拿到新的使用机会。

使用政策真正承诺的身份

如果规则是按账号限流,就应从认证结果中取得账号,而不是相信调用方提交的账号请求头或查询参数。仅仅看见一个凭据,并不能证明它有效;认证层必须先验证凭据、解析主体,再让账号级准入决定使用这个结果。限流键的可信程度不能高于它的来源。

按凭据限流也可以是合理的产品契约,但行为与按账号限流不同。轮换凭据或再创建一份凭据,可能得到新的凭据键,却仍属于同一个账号。应先决定这是否应当带来新额度,而不是等出现滥用或投诉后,才发现实现按的身份与产品描述并不一致。

一个账号不一定只对应一个人或一个应用,一个人也可以拥有多个账号。稳定账号键改善的是归组的一致性,并不证明公平,不阻止批量养号,也不限制整个服务的总需求。其他层级的限制应由明确需求支持,不能认为“已经按账号计数”就涵盖所有容量风险。

地址表示的是网络路径

企业 NAT、运营商网络或 VPN 后面的多个账号,可能共用一个公网地址;按 IP 的桶会把它们的流量合在一起。反过来,更换地址的调用方可能进入一个新桶。这两种结果并不说明地址解析错误,而是说明网络地址与业务主体本来就在表示不同对象。

没有已验证账号的公开接口或认证前入口,仍可以用地址级限制保护资源。但应按地址政策评估阈值,包括正常共享网络的流量,不能把它描述成精确的个人限制,也不能假设一个地址规则一定拦得住分散在许多网络上的抓取者。

地址在成为键之前需要语法规范化。同一个 IPv6 地址的不同合法写法,不应创建多份额度。是否按网段聚合,或把特定地址族形式视为同一个对象,是另外的政策选择,需要权衡误伤范围;规范化文本本身并不能替你回答这些业务问题。

转发身份必须有信任边界

随意提交的 Forwarded、X-Forwarded-For 或类似字段,不是客户端来源的证明。RFC 7239 明确指出,客户端和沿途节点都能改变转发信息。是否可信取决于直接连接的对端、配置的代理链,以及这条链如何移除或安全处理用户原先提交的字段。

NGINX 的真实 IP 模块需要明确配置可信来源地址和递归处理规则。“永远取转发字段里的第一个地址”不是适用于所有网络的正确方法。如果代理接收后原样转发了不可信声明,看似稳定的身份键就可能变成由调用者控制的新预算生成器。

下面的实验只模拟一个已经配置的可信代理,它会覆盖字段,并只发送一个 IP 字面值。其他对端提交的替换声明被忽略,来自这个代理的多地址列表则被拒绝。它不是通用代理链解析器,也不是已经部署并验收的 NGINX 配置,不能直接扩大其覆盖范围。

坏掉的样子

键里漏掉租户维度,会合并不同租户中标识相同的账号。按原始 URL 计数,又可能因为查询参数不同,把同一个操作拆成多个桶;如果政策按操作限流,应使用路由解析出的稳定操作身份。直接拼接字符串还可能产生歧义,应在编码中保留各组件边界。

不要把原始 bearer 秘密作为预算键或诊断标签。稳定的内部凭据标识或主体标识通常更合适,日志也仍需要隐私控制。无限创建身份会消耗限流器内存;评估过期或状态淘汰时,还应考虑桶被重置后产生的新额度,不能只看到内存回收这一面。

选定阈值前,应测试共享地址、漫游客户端、凭据轮换、租户隔离和伪造声明等真实身份组合。还要沿数据来源检查所谓“已验证主体”是否真的来自认证结果。如果它本身是从不可信请求直接复制出来的,后面再精巧的字典键也修复不了这个信任错误。

快速验证

保存为 identity_check.py,用 Python 3.10 以上版本执行 python3 identity_check.py。实验已在 Python 3.12.14 上运行,只使用标准库。记录代表已经完成认证和路由解析的测试请求,并不是认证实现;计数器只模拟一个测试窗口,不启动服务,不发送网络请求,也不写文件。

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 耗尽地址桶后,同地址的 Bob 也会被阻止;按账号归组则保留 Bob 自己的额度。Alice 更换地址可以得到新 IP 额度,但换地址、轮换凭据标识,或者伪造账号声明,都不会改变这里的账号键。这个结果依赖测试记录中的主体已经可信,不能反过来作为认证正确性的证明。

不同租户与不同操作得到独立键,元组保留组件边界,避免分隔符碰撞。非可信对端不能选择替换地址,同一个 IPv6 地址的等价写法被统一;单代理、单地址契约会拒绝地址列表。真实认证、代理转发、并发计数、分布式配额和生产身份数量都未在本例中验证。

延伸阅读

来源

  • RFC 6585,第四节将身份选择与计数范围交给限流政策定义,已核验 2026-09-08。
  • RFC 7239,转发字段完整性、可信代理与信息暴露,已核验 2026-09-08。
  • NGINX 真实 IP 模块,可信替换地址与递归处理,已核验 2026-09-08。