会话、令牌与客户端凭证安全:从重放检测到可信退出
凌晨的告警显示,同一 refresh token 在两个地区相隔数秒被使用。系统确实开启了“刷新令牌轮换”,却没有保存 token family 和前驱关系:两个请求都在读取旧值后成功签发了新 token,攻击者与真实用户各自得到一条长期有效的分支。团队随后把 access token 有效期缩短,却没有撤销这两条 refresh 链,风险只从一个长寿命凭证转移成了持续换取短凭证的能力。
另一个退出故障更加隐蔽。用户点击退出后浏览器 cookie 消失,页面也跳回登录页,但移动端 refresh token、上游身份提供方会话和已泄露的 API token 仍然有效;旧客户端 secret 甚至还在定时任务里持续换取新 token。只看前端状态,退出已经成功;沿会话、授权、客户端身份和资源端缓存逐层验证,才会发现系统只清理了最外面的一层指针。
先为每一种凭证画出持有者和失效点
浏览器 session cookie 通常只保存不透明句柄,服务端 session 保存用户、认证上下文、空闲与最长寿命以及下游授权关联;ID Token 向 OIDC 客户端表达一次认证结果;access token 允许调用特定资源;refresh token 延续授权并换取新 token;客户端 secret、私钥或证书证明客户端自身。它们的接受者、泄露后能力和撤销位置都不同,不能统称为“登录 Token”。
最有用的台账不是秘密值清单,而是关系图:谁签发、谁持有、谁验证、绑定哪个 issuer/client/session/grant/resource、在哪里持久化、何时轮换、通过什么事件失效。一个浏览器退出动作可能只删除 cookie;一个 grant 撤销可能使 refresh token 失效却保留业务服务端 session;客户端 secret 轮换也不会自动注销已签发 token。安全设计必须明确每条边由哪个系统关闭。
browser cookie -> application session -> upstream login session
| |
+-> OAuth grant -----+
|
+-> refresh-token family
| +-> access token -> resource
+-> client authentication credentialOAuth 的现行部署基线来自 RFC 9700:公共客户端必须使用 PKCE,授权服务器必须支持 PKCE;redirect URI 精确匹配;资源所有者密码凭据模式不得使用;implicit 不应作为新实现默认;refresh token 对公共客户端必须采用 sender constraint 或 rotation 以发现重放,并应把 access token 限制到最小权限和资源。OAuth 2.1 仍是需从 Datatracker 查询状态的工作草案,不是发布完成的 RFC,也不应成为唯一审计依据;草案修订号和最终文本都不能写进长期策略常量。
浏览器会话要同时绑定来源、期限和服务端状态
cookie 的 Secure 阻止明文 HTTP 发送,HttpOnly 降低脚本直接读取风险,SameSite 参与跨站请求限制,Path 与 Domain 缩小发送范围。它们都不能修复 XSS、错误 CORS、会话固定、服务端越权或代理信任错误。认证前后应更换 session ID,权限提升、强认证和账号切换后也应旋转,避免攻击者预先种下的句柄在登录后获得身份。
会话至少有 idle timeout 与 absolute lifetime。用户活动可以延长空闲窗口,但不能无限突破最长寿命;安全事件、密码或 MFA 重置、账号停用、风险策略变化时还需要主动撤销。时间值来自业务风险、使用场景和目标环境能力,不应把某个示例分钟数复制到所有应用。服务端比较使用一致时钟,前端倒计时只是体验,不是失效裁决。
状态型 session 能即时撤销和保存细粒度上下文,但需要共享存储、复制和清理;完全自包含的 cookie 减少存储读取,却让服务端难以即时收回权限,并把敏感 claim 暴露给浏览器存储边界。即使内容加密签名,也要处理密钥轮换、大小限制、重放和旧权限持续时间。对需要强制离职、设备管理或高风险操作的系统,保留可查询的服务端状态通常更容易形成撤销证据。
反向实验应包含固定旧 session ID 登录、跨站 POST、过期后继续请求、权限降级后复用旧 cookie、并发退出与请求竞争。真正的成功标准不是 /logout 返回 302,而是旧句柄在所有节点和副本都拒绝,CSRF token 与 session 一起失效,代理不会把攻击者提供的身份头转发给应用,审计能够说明由谁、因何、在哪个策略版本终止会话。
access、ID 与 refresh token 不能互换
ID Token 的 audience 是 OIDC 客户端,资源服务器通常不是它的接受者。access token 的格式由授权服务器与资源服务器合同决定,可以是不透明字符串,也可以是 JWT;仅当部署明确采用相应 profile 时,资源端才能依合同验证 JWT claims。把一个能解码的 ID Token 塞进 Authorization: Bearer,或把任意 JWT 都当作 access token,是典型的 confused deputy 起点。
RFC 6750 说明 bearer token 的持有即授权性质。它应通过 TLS 下的 Authorization header 发送,不进入普通 URI query、Referer、浏览器历史、代理 access log 或指标标签。资源端固定允许的 issuer、token 类型、算法、audience/resource、scope、时间窗口和调用主体;验签成功只证明内容由某把受信 key 签发,不证明该 token 是给当前 API、当前动作或当前租户使用。
refresh token 通常只发给授权客户端,不能交给资源 API。它权限大、寿命长、使用频率低,适合放在 OS secure storage、受保护后端或加密数据库中;浏览器脚本可读存储会把 XSS 直接升级成长会话窃取。原生应用和 SPA 的具体持久化能力不同,必须结合平台安全存储与 BFF 架构判断,不能因为用了 PKCE 就认为 refresh token 可安全放在任意 local storage。
项目接入时,把令牌用途写入接口,而不是让一个 token 字符串流遍全栈。业务后端只从认证中间件接收已验证 principal 与授权上下文;下游客户端按目标资源请求或交换专用 access token;日志结构只接收 token 指纹和 metadata。这样测试可以稳定拒绝错受众、错 issuer 和错 token type,代码评审也能看到 ID Token 是否越过了客户端边界。
type VerifiedAccess = {
issuer: string;
subjectId: string;
clientId: string;
audience: readonly string[];
scopes: ReadonlySet<string>;
expiresAt: number;
tokenFingerprint: string;
};
async function readOrders(auth: VerifiedAccess) {
if (!auth.audience.includes(process.env.ORDERS_RESOURCE!)) {
throw new Error("access_denied: audience_mismatch");
}
if (!auth.scopes.has("orders:read")) {
throw new Error("access_denied: scope_missing");
}
return ordersRepository.findForSubject(auth.subjectId);
}refresh rotation 必须是原子状态机
rotation 的核心不是“每次返回一个新字符串”,而是授权服务器维护 family、当前代次和使用状态。正常刷新原子消费当前 token,创建下一代并更新当前指针;已消费 token 再出现就是重放信号。系统应区分网络超时后的幂等恢复与真正并发复用,并按风险策略撤销整个 family、当前 session 或要求重新认证。没有 family 关系,旧 token 失窃后只会变成另一条无人发现的分支。
数据库事务或原子 compare-and-swap 必须包住读取、消费与签发记录。先签发再标旧值失效会留下双活窗口;多节点只在本地缓存状态会让同一 token 在不同区域各成功一次。token 表保存不可逆哈希、family ID、generation、状态、client、subject、session、创建与消费相对时序以及原因,不保存可直接使用的 refresh token 原文。
下面是可复制的状态模拟。第一次消费输出 ROTATED,随后重放旧 token 输出 REPLAY_DETECTED 并把 family 标记为 revoked;此时新 token 也不能再使用。生产实现要把同样的不变量放入数据库唯一约束和事务中,而不是依赖单进程 Map。
const family = { revoked: false, current: "r0", used: new Set() };
function rotate(presented) {
if (family.revoked) return "FAMILY_REVOKED";
if (family.used.has(presented) || presented !== family.current) {
family.revoked = true;
return "REPLAY_DETECTED";
}
family.used.add(presented);
family.current = `r${family.used.size}`;
return "ROTATED";
}
console.log(rotate("r0")); // ROTATED
console.log(rotate("r0")); // REPLAY_DETECTED
console.log(rotate("r1")); // FAMILY_REVOKED并发正向测试应让同一客户端的刷新请求在 SDK 内单航班,所有等待者共享一次结果。反向测试用栅栏让两个服务端请求同时提交同一旧 token,验收不变量是最多一个成功,另一个产生 replay 事件,并且策略要求撤销时新代次无法继续刷新。不能把偶发 invalid_grant 都归为攻击:时钟、客户端重试、数据库复制延迟和非原子持久化也会产生相似表象,证据必须包含 family、generation、节点与因果顺序。
audience、scope 与持有者绑定限制横向移动
一个可被所有内部 API 接受的 bearer token,会把任意服务失陷扩成整网横向移动。授权服务器应按资源签发 audience 限定 token,客户端只请求当前操作需要的 scope,资源端既验证 audience,也执行对象级和租户级授权。scope 是授权输入,不等于数据行权限;orders:read 不能让用户读取所有客户订单。
RFC 8707 的 resource indicator 可帮助客户端表达目标资源,但授权服务器、client 和 API 是否采用、资源标识如何注册,都必须从目标环境确认。服务 A 调服务 B 时,可以进行 token exchange 或取得面向 B 的专用 token;直接转发面向 A 的用户 token 会扩大受众并混淆 A 自身的责任。调用链审计要同时保留原始用户、委托客户端和当前工作负载身份。
bearer 泄露防护还可以使用 sender-constrained token。DPoP 通过应用层 proof 将 token 绑定到密钥,mTLS 可把 token 绑定到客户端证书。两者都需要授权服务器与资源服务器一致验证绑定,并处理密钥、nonce、代理终止和重放缓存;产品是否支持、支持哪个 flow 与算法不能由协议存在推断。若 TLS 在代理终止,后端不能信任任意客户端可伪造的证书头。
正反验收至少包括:正确 audience 与 scope 成功;错 audience 即使签名有效也返回 401;缺 scope 或对象权限返回 403;同一 DPoP proof 重放被拒绝;token 与错误公钥或证书组合被拒绝;另一个租户的合法 token 不能访问当前租户资源。错误码既要符合资源协议,也要避免把过多策略细节泄露给攻击者。
撤销、logout 与密钥轮换是三条传播链
RFC 7009 定义 token revocation,但撤销未知 token 也可以返回成功,因此 200 不能证明资源端已经拒绝旧 token。RFC 7662 的 introspection 可向受保护调用方提供 active 等状态,但响应包含主体、client、scope 与授权信息,本身需要认证授权、TLS、缓存约束与最小返回。自包含 JWT 则常依赖短 TTL、撤销事件或 denylist;每种方案都有不同传播延迟和故障模式。
可执行的撤销实验使用专用测试 client,避免 shell tracing 和真实 token 落盘。下面的命令骨架先访问资源、调用撤销、再轮询资源直到进入传播预算;认证方法、端点与参数来自注册合同,不能擅自假设 basic secret。
set -eu
: "${RESOURCE_URL:?}" "${REVOCATION_URL:?}" "${ACCESS_TOKEN:?}"
curl --fail --silent --show-error \
-H "Authorization: Bearer $ACCESS_TOKEN" "$RESOURCE_URL" >/dev/null
curl --fail --silent --show-error \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "token=$ACCESS_TOKEN" \
--data-urlencode 'token_type_hint=access_token' \
"$REVOCATION_URL" >/dev/null
# 真实测试由 harness 在撤销传播预算内重试;最终必须获得拒绝。
if curl --fail --silent --show-error \
-H "Authorization: Bearer $ACCESS_TOKEN" "$RESOURCE_URL" >/dev/null; then
echo 'WAIT: resource has not observed revocation yet'
else
echo 'PASS: revoked token rejected by resource'
filogout 需要分别处理应用 session、上游 OP session、refresh family 与设备会话。front-channel 依赖浏览器,可能被第三方 cookie 策略或页面关闭影响;back-channel 依赖服务间通知、幂等处理和重试;本地退出至少立即拒绝当前 cookie。究竟启用何种 OIDC logout 能力,要从目标 OP metadata、客户端注册和库文档确认,不能把一个产品的 end-session URL 写成通用约定。
签名 key 与客户端 credential 的轮换又是另一条链。签名 key 采用先发布新公钥、再用新私钥签发、等待缓存与旧 token 窗口、最后移除旧公钥;客户端 secret 或私钥采用新旧并存、客户端切换、观测旧凭证使用、撤销旧值。紧急失陷可能缩短窗口并强制重认证。删除旧 JWKS key 不能让已经被资源缓存的 key 瞬时消失,所以必须测量每一层缓存与失败策略。
故障证据与敏感数据必须一起设计
会话安全排障先找到第一个仍接受旧凭证的边界。cookie 已删除但服务端 session 存在,问题在应用;session 已撤销但 refresh 仍能使用,问题在 grant/family;refresh 已拒绝但 access token 仍能调用,查看 token TTL、introspection/denylist 和资源缓存;新 token 被错误 key 拒绝,查看 JWKS 发布、缓存与 issuer 绑定;旧 client secret 仍能换 token,检查 token endpoint 的凭证版本和各节点配置,而不是继续清浏览器缓存。
事件模型可以包含 session_created、session_rotated、session_revoked、refresh_rotated、refresh_replay_detected、token_rejected、credential_version_used、credential_revoked 和 logout_propagation_failed。字段使用 issuer、client、resource、匿名化 subject/session/family、credential version、kid、策略版本、错误类别与 correlation ID。指标只按低基数维度聚合,不能把 subject、session、完整 endpoint 或 token fingerprint 当 label。
client secret、私钥、refresh token、access token、cookie、authorization code 和设备码都不进入日志、trace、崩溃转储、截图、工单或 CI artifact。ID Token/UserInfo claims、introspection 响应、IP、设备信息和审计事件可能包含个人或行为数据,需要目的限制、字段最小化、访问审批和保留删除策略。排障关联使用带独立密钥的 HMAC 指纹,避免普通哈希被字典或已知 token 对照利用;指纹密钥也要轮换并隔离。
支持包与数据库备份经常绕过应用脱敏。导出前应用字段白名单,默认剔除 header、cookie、body、环境变量和 secret mount;备份继承生产数据级别,恢复演练在隔离环境进行。临时提高日志级别不能打印认证中间件对象,因为很多库对象的字符串表示会包含 token 或 claim。事故取证需要原始数据时,应走受控证据库和限时访问,不把生产凭证复制到开发机。
容量和成本决定撤销能多快传播
状态型 session、refresh family、replay cache、denylist 和审计日志都消耗写入与存储。容量估算至少包含活跃 session、每秒创建/刷新/撤销峰值、每 family 代次数、最大 token/session 窗口、设备数、跨区复制延迟、JWKS/introspection QPS 和安全事件突发量。清理任务不能只按创建时间删除:replay 记录要覆盖其可能被接受的最长窗口,过早删除会让旧凭证重新变成“未知但可试”。
在线 introspection 提供更及时的状态,却把每次 API 调用与授权服务器可用性绑定;缓存降低成本,又增加撤销传播延迟。不透明 token 便于中央控制,但多一次网络查询;JWT 可本地验证并扩展吞吐,但权限变更在过期前可能继续有效。选择应以撤销 SLO、API 延迟预算、故障域、隐私和峰值测量为依据,不以“JWT 无状态”或“数据库更安全”的口号决定。
缓存策略必须显式区分正常与故障:JWKS 未知 kid 使用单航班刷新和负缓存;introspection 只缓存到响应与 token 剩余寿命允许的较短窗口;撤销事件消费要幂等并监控积压;跨区 session 选择一致性时要知道登出是否允许短暂延迟。生产阈值来自压测与风险预算,文章中的任何固定数值都不应替代目标系统基线。
成本还包括 HSM/KMS 操作、跨区数据库、审计索引、客服设备下线、移动端升级和遗留 client 轮换。过短 access token 会制造刷新峰值,过长 token 会放大失陷窗口;所有客户端在整点刷新会造成同步洪峰。客户端应在过期前随机抖动刷新,服务端按 client/subject/family 限并发并提供有界退避,异常重试不能把授权服务器拖垮。
团队用生命周期门禁完成迁移和退出
平台团队负责授权服务器、key、session/family 存储与撤销传播;应用团队负责 cookie、CSRF、BFF 和本地 session;资源团队负责 access token 合同与对象授权;安全团队维护算法、凭证级别、检测与应急策略;业务 owner 决定账号、设备和授权关系何时结束。每个 client、issuer、key、secret、session store 和资源 audience 都需要 owner 与替代路径,不能由离职个人长期持有。
轮换门禁采用“创建新值、双值验证、切换签发或调用、观测旧值、撤销旧值、持续负向探测”。secret、私钥、证书和签名 key 的消费者不同,不能用一次环境变量替换覆盖。对无法双凭证并存的客户端,按 client 分窗口,并准备回到旧版本但不恢复已确认泄露凭证的方案。自动化扫描只检查配置存在不够,还要从 token endpoint、资源端和审计证明旧值真正失效。
从长期 session 或静态 API key 迁移到短期 token 时,先建立资源 audience、scope 与主体映射;让资源端并行识别新合同,客户端逐个切换并观测旧凭证使用者,再撤销旧 key。权限变化期间避免把旧 key 的宽权限直接复制给新 token;利用迁移重做最小权限。回滚只回滚软件路径,不应让已撤销 key、旧 refresh family 或已下线 issuer 重新有效。
退出 client、租户或整套身份平台时,先停止新授权和新 session,列出活跃 session、grant、refresh family、客户端 credential、签名 key、设备与资源端信任;完成用户迁移后撤销并验证旧链,移除回调、logout、issuer/audience、JWKS 和 introspection 信任;清理 session/replay/denylist 缓存、备份副本与外部 secret;按法规和审计策略保留最小证据。最终应持续看到旧 cookie、旧 access/refresh token、旧 secret、旧私钥和旧 issuer 新签 token 全部被拒绝,同时新平台可完成登录、刷新、授权和退出。这个反证比“控制台应用已删除”更接近真正的安全退出。
