某金融 App 的用户凌晨两点收到推送:在陌生城市登录。用户打开 App,点「退出所有设备」。三秒后运营后台收到风控告警:该账号在另一个 IP 上刚刚完成了一笔 8 万元的转账——原因是攻击者拿到的 access token 还有 11 分 20 秒才过期,而「退出所有设备」的按钮只是把 refresh token 从数据库里删了。
这不是假设,是 JWT 无状态设计的固有代价。上一篇 讲完了 JWT 的签发与验证,本篇要回答的问题只有一个:令牌签出去之后,怎么把它收回来。答案不是「用 Session 就行了」,也不是「把 exp 改短就行了」,而是一整套由 access token、refresh token、rotation、reuse detection、黑名单、pub/sub 广播、OIDC logout 端点组成的吊销体系。
本文所有代码都按生产可落地的方向写,Redis 键、TTL、Bloom
filter 参数都给具体数值。如果你正在做 认证架构设计
或者在为 OAuth2
授权服务器 实现 /revoke
端点,这篇是配套手册。
一、无状态 JWT 的吊销困境
1.1 吊销是一个状态问题
JWT 的核心卖点是无状态(stateless):服务端不用存任何
session 表,拿到 token 验签、查 exp、查
aud,通过就认了。这让水平扩展变得极其简单——加机器就完事,不需要黏性会话、不需要共享存储。
但「吊销」本质上是一个状态变更:在某个时刻
T,系统决定这个 token
从此刻起不再有效。无状态验签拿不到这个「此刻起」的信息。签发时嵌入
exp
确定了一个未来的失效时间点,但吊销要求一个任意的失效时间点。
这两个目标数学上是矛盾的:只要验签不查询任何共享状态,系统就无法知道某个 token 是否在 T 时刻被吊销过。想解决吊销,要么放弃纯无状态(引入共享状态查询),要么放弃吊销语义(只靠过期时间收敛)。
1.2 为什么「把 exp 调短」不够
最朴素的想法:把 access token TTL 从 1 小时改成 1 分钟,这样最多泄露 1 分钟。这条路有三个死胡同。
第一,每分钟重新登录一次显然不可接受。于是必须引入 refresh token——长期有效的凭据,用它定期换新的 access token。问题只是被推后了:refresh token 本身也会被泄露,refresh token 的吊销回到原问题。
第二,1 分钟在很多攻击场景下足够造成损失。自动化的撞库工具每秒可以发起几百个请求,一分钟内可以完成密码修改、邮箱绑定、资金转出、下载私密数据。把 TTL 压到 10 秒呢?那签名验证本身的开销(每次 RPC 都 RS256 验签一次)会成为瓶颈,auth server 的签发 QPS 也会被放大 100 倍。
第三,时钟不同步会让短 TTL 变成灾难。集群里机器的 NTP 偏差通常在 100ms 以内,但极端情况可能到几秒甚至几十秒(虚拟机漂移、NTP 服务挂了、跨机房时区配置错误)。access token TTL 1 分钟、允许 leeway 30 秒,某台机器偏快 20 秒,用户会遇到「刚签发的 token 已过期」。
业界收敛的参数大致是:
| 场景 | access token TTL | refresh token TTL | 说明 |
|---|---|---|---|
| 公网 Web / 移动端 | 5-15 分钟 | 7-30 天 | 主流 |
| 金融 / 医疗高敏感 | 1-5 分钟 | 24 小时,idle 30 分钟 | 强制 MFA 重验 |
| 机器间(workload) | 5-60 分钟 | 不用(mTLS 或 SPIFFE) | 没有人在操作 |
| 设备绑定(IoT) | 1 天 | 180 天,device-bound | 配合 DPoP / mTLS |
5-15 分钟是一个经验值:既短到让吊销延迟大多数时候可接受,又长到让 auth server 的刷新 QPS 不爆炸。要做到比 5 分钟更短的强实时吊销,必须引入显式的吊销列表或者 token introspection。
1.3 Session 方案为什么不能直接搬回来
有人会说:那我不用 JWT 了,退回 session 不就行了。在 认证架构 那篇讲过,session 方案在微服务 / SSO 架构下有本质性的问题:
- 每次 API 调用都要查一次共享存储(Redis / DB),RS 和 auth server 强耦合;
- 跨域、跨子域、跨产品线的 cookie 传递成本高;
- 移动端和 SPA 要模拟 cookie 行为,反而更复杂;
- 多活机房下 session 同步延迟会造成概率性认证失败。
所以主流方案不是在 session 和 JWT 之间选一个,而是用 JWT 做常规验证、用 session 风格的共享状态做吊销检查——这就是下一节要讲的混合模式。
二、混合模式:短 access token + 长 refresh token + 吊销列表
2.1 职责划分
整个模型里有四个主体:
- Access Token(AT):无状态 JWT,短
TTL,带在
Authorization: Bearer头上访问 resource server。RS 本地验签即可,不查共享存储(或只查一个命中率极低的黑名单)。 - Refresh Token(RT):长 TTL,只在 auth
server 的
/token端点露面,用来换 AT。可以是 opaque string,也可以是 JWT;工程上推荐 opaque + 服务端存储。 - 黑名单(Blacklist) 或 白名单(Whitelist):共享状态,记录被吊销的 AT 或所有有效的 RT。
- 事件广播:吊销事件通过 pub/sub 通知所有 RS 节点,让本地缓存失效。
2.2 黑名单 vs 白名单
两种思路,选哪个取决于日吊销事件数和日签发事件数的比例。
白名单(allowlist):所有有效 token 都进存储,验证时查存在性。等价于 session 模式,每请求查存储。
- 优点:吊销 = 从白名单里删一条记录,语义简单;内存占用 = 在线用户数 × 会话数,可控。
- 缺点:每 API 请求都要查 Redis,延迟增加 0.5-2ms,QPS 高时成为瓶颈。
- 适用:高敏感(银行、医疗)、会话数可控(后台系统)。
黑名单(denylist):只记录被吊销的 token,验证时查不存在性。
- 优点:大多数请求命中「不在黑名单」的快路径,配合 bloom filter 可以做到近乎零成本。
- 缺点:内存占用 = 过去 TTL 窗口内的吊销数,吊销率高时会膨胀;如果吊销事件漏广播,token 不会失效(故障放大)。
- 适用:通用场景,吊销事件稀疏。
大多数互联网系统选黑名单 + bloom filter 组合,金融和企业级系统选白名单。下面的实现都以黑名单为主,必要时会标注白名单的差异。
2.3 端到端流程
登录(首次):
Client POST /login {user, pwd, device_id}
Auth 验证凭据 → 生成 AT (exp=15m) + RT (exp=30d, family_id=F1)
存 RT: SET rt:{hash(rt)} {uid, fid=F1, parent=null} EX 30d
Client ← 200 {access_token, refresh_token}
API 调用:
Client GET /api/xxx Authorization: Bearer AT
RS 1. 验签(本地 JWKS)
2. 校验 exp / aud / iss
3. 查黑名单 revoked:{jti}(bloom filter 先过,miss 跳过 Redis)
4. 通过 → 返回业务数据
刷新:
Client POST /token {grant_type=refresh_token, refresh_token=RT1}
Auth 1. hash(RT1) 查 rt:{...} 是否存在
2. 若不存在 → reuse detection → 吊销 family F1 全部
3. 若存在 → 标记 RT1 已使用 / 删除 → 生成 AT2 + RT2(family_id=F1, parent=RT1)
4. SET rt:{hash(RT2)} ... EX remaining_lifetime
Client ← 200 {access_token=AT2, refresh_token=RT2}
吊销(用户主动或管理员):
Admin POST /revoke {user=U, scope=all}
Auth 1. 扫描 user_sessions:{U} 得到所有 fid
2. 批量 SET revoked:{fid}:* + SET user_rev:{U} now()
3. PUBLISH revoke {type=user, uid=U, not_before=now()}
RS 节点 SUBSCRIBE revoke → 更新本地 bloom + cache
几个关键细节:
- AT 是 JWT,RT 是 opaque(或 JWT 包 opaque)。RT 必须存 hash 而不是明文,否则数据库泄露等同于密码明文泄露。
- RT 存储用 Redis 即可,TTL 天然等于 RT 的 exp。
revoked:{jti}的 TTL 必须 = AT 剩余寿命,不是永久。token 过期后黑名单记录自动失效,不会无限膨胀。- family_id 贯穿一次登录后的所有 rotation,是 reuse detection 的关键。
三、Refresh Token Rotation 与 Reuse Detection
3.1 为什么要 rotation
一个 refresh token 活 30 天,用户用手机 App 每天刷新 5 次,30 天就是 150 次刷新。如果 RT 不轮换,那这 30 天里任何一次网络包被截获、任何一次设备被借用留下的 token 残留,都够攻击者在接下来几天里无限续命。
Rotation 的规则很简单:每次用 RT 换 AT 时,同时发一个新 RT、废掉旧 RT。合法用户拿到新 RT,旧 RT 被标记为已使用(used)。下次刷新用新 RT,再发新 RT,如此链式推进。
单独看 rotation 并不能阻止攻击:攻击者拿到 RT 后立刻刷新,也能拿到新 RT,后续他能用下去。真正有用的是配合 reuse detection。
3.2 Reuse Detection 与 family-based abort
核心观察:一个 RT 只应被使用一次。如果服务端看到同一个 RT 被用了第二次,必然有一方是攻击者。两种情况:
- 合法用户先刷新,RT1 → RT2,合法用户拿到 RT2;攻击者后来用 RT1 刷新 → 服务端发现 RT1 已使用。
- 攻击者先刷新,RT1 → RT2’(攻击者拿到 RT2’);合法用户后来用 RT1 刷新 → 服务端发现 RT1 已使用。
服务端无法分辨哪一个是攻击者,但可以确定:这个家族已经被污染。正确响应是把整个 family 的所有 RT 全部吊销,强制双方重新登录。合法用户会被迫登录一次,攻击者失去所有凭据。这就是 family-based abort。
family_id = F1
RT1 (used) → RT2 (used) → RT3 (active)
↑
攻击者重放 RT1
检测:RT1 already used
动作:DEL family:F1, DEL 所有 rt:{hash(RTi)} where fid=F1
PUBLISH revoke {type=family, fid=F1}
所有 AT(jti 属于 F1)进黑名单直到各自 exp
实现存储结构:
rt:{hash(RT)} → {uid, fid, parent_jti, used=false, exp}
family:{fid} → {uid, created_at, last_rt_jti, status=active|compromised}
user_sessions:{uid} → SET of fid(用户当前所有 family)
rotation 时的原子操作至关重要,必须用 Lua 或 MULTI:
-- KEYS[1] = rt:{hash(old_rt)} KEYS[2] = family:{fid}
-- ARGV[1] = new_rt_json ARGV[2] = new_rt_key
-- ARGV[3] = ttl_seconds
local rt = redis.call('GET', KEYS[1])
if not rt then
redis.call('HSET', KEYS[2], 'status', 'compromised')
return 'REUSE'
end
local parsed = cjson.decode(rt)
if parsed.used then
redis.call('HSET', KEYS[2], 'status', 'compromised')
return 'REUSE'
end
redis.call('DEL', KEYS[1])
redis.call('SET', ARGV[2], ARGV[1], 'EX', ARGV[3])
redis.call('HSET', KEYS[2], 'last_rt_jti', ARGV[2])
return 'OK'如果返回 REUSE,应用层触发 family 全部吊销:
func (s *AuthServer) abortFamily(ctx context.Context, fid string) error {
keys, err := s.redis.Keys(ctx, "rt:*:fid="+fid).Result()
if err != nil { return err }
if len(keys) > 0 { s.redis.Del(ctx, keys...) }
members, _ := s.redis.SMembers(ctx, "family_jtis:"+fid).Result()
pipe := s.redis.Pipeline()
for _, jti := range members {
pipe.Set(ctx, "revoked:"+jti, "1", s.atMaxTTL)
}
pipe.SAdd(ctx, "compromised_families", fid)
pipe.Publish(ctx, "revoke", fmt.Sprintf(`{"type":"family","fid":"%s"}`, fid))
_, err = pipe.Exec(ctx)
return err
}生产实现里 Keys * 要换成 SCAN
或者直接用反向索引(family:fid 下维护一个
SET),千万不要在生产 Redis 上 KEYS 大量键。
3.3 Family abuse 检测
Reuse detection 解决单个 RT 被重放的情况,但另一类异常需要额外检测:同一 family 在短时间内产生了异常多的 rotation。
合法用户一天刷新 5-20 次很常见。但如果一个 family 在 1 小时内刷新了 200 次、同时 user-agent 频繁变化、同时来自多个 IP 段——这是典型的滥用。可能是:
- 脚本在拿到 RT 后自动扫描所有业务接口。
- 分布式撞库工具用一个账号做跳板。
- 客户端 bug 死循环刷新。
检测规则示例:
def detect_family_abuse(fid: str, new_event: RefreshEvent) -> bool:
key = f"family_metrics:{fid}"
now = int(time.time())
pipe = r.pipeline()
pipe.zadd(key, {f"{new_event.jti}:{now}": now})
pipe.zremrangebyscore(key, 0, now - 3600)
pipe.zcard(key)
pipe.expire(key, 7200)
_, _, count, _ = pipe.execute()
ips = r.smembers(f"family_ips:{fid}")
uas = r.smembers(f"family_uas:{fid}")
r.sadd(f"family_ips:{fid}", new_event.ip); r.expire(f"family_ips:{fid}", 7200)
r.sadd(f"family_uas:{fid}", new_event.ua); r.expire(f"family_uas:{fid}", 7200)
if count > 100: return True
if len(ips) > 10 and count > 30: return True
if len(uas) > 5 and count > 20: return True
return False命中后走 family abort 流程,但通常会降级为「要求用户重新登录 + MFA」,而不是直接封号,以避免误伤。
3.4 Rotation 的并发陷阱
移动端很容易出现并发刷新:App 主进程和 WebView 同时发现 AT 过期,同时用同一个 RT 发起 /token 请求。如果服务端严格要求 RT 只能用一次,其中一个请求会被判定为 reuse,family 被误杀。
工程上的两种处理方式:
宽限窗口(grace window):RT 被使用后不立刻删除,而是保留 10-30 秒,这段时间内相同 RT 的再次请求返回相同的新 RT(幂等)。超过窗口后再请求才判定 reuse。
func (s *AuthServer) rotate(ctx context.Context, oldRT string) (*TokenPair, error) {
key := "rt:" + hash(oldRT)
val, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil {
cached, err2 := s.redis.Get(ctx, "rt_grace:"+hash(oldRT)).Result()
if err2 == nil {
return parseTokenPair(cached), nil
}
s.abortFamily(ctx, extractFid(oldRT))
return nil, ErrReuseDetected
}
newPair := s.mintTokens(...)
s.redis.Set(ctx, "rt_grace:"+hash(oldRT), serialize(newPair), 30*time.Second)
s.redis.Del(ctx, key)
s.redis.Set(ctx, "rt:"+hash(newPair.RT), ..., 30*24*time.Hour)
return newPair, nil
}客户端互斥锁:App 层面保证同一时刻只有一个请求在刷新,其他请求等待。大多数成熟 SDK(AWS Amplify、Auth0 SDK、MSAL)都内置这个逻辑。
两种方法通常组合使用:客户端尽力避免并发,服务端 grace window 兜底。
四、统一登出(SLO):Back-channel vs Front-channel
4.1 SLO 的真实难度
Single Logout(SLO)说起来简单:用户在任意一个应用点退出,所有 SSO 关联的应用都跟着退出。实际做起来是整个 OIDC 规范里最不稳定的部分,很多 IdP 直接标注 SLO 为「尽力而为,不保证」。
难度来自两个方向:
- 用户已经离开那个 App 的页面。点退出时用户在 App A,App B C D 的页面可能没打开。怎么让 B C D 知道该登出?
- 跨域的浏览器状态无法直接同步。App B 的 cookie 在 b.com 域下,A 的退出请求无法直接操作 b.com 的 cookie。
OIDC 规范给了三种模式:RP-Initiated Logout、Front-Channel Logout、Back-Channel Logout。三个不是互斥的,实际部署往往组合。
4.2 RP-Initiated Logout:用户主动触发
用户在某个 RP(Relying Party,依赖方)点「退出」,RP
通过重定向把浏览器带到 OP(OpenID Provider)的
end_session_endpoint:
GET /oidc/logout?
id_token_hint=eyJhbGciOi...
&post_logout_redirect_uri=https://app.example.com/goodbye
&state=af0ifjsldkj
参数含义:
id_token_hint:上次登录拿到的 id_token,让 OP 能识别是哪个会话。可选但强烈推荐,没有它 OP 可能要求用户再确认一次。post_logout_redirect_uri:登出后要回跳的地址,必须在 client 注册时登记的白名单内。state:防 CSRF。
OP 收到后执行:
- 用
id_token_hint定位 OP 端 session。 - 清除 OP 的 session cookie。
- 吊销 RT / AT(把 family 标记为 logged_out)。
- 触发 front-channel 或 back-channel 通知其他 RP。
- 重定向回
post_logout_redirect_uri。
这一步只是「这个 RP 知道了用户要登出」。其他 RP 怎么同步,靠下面两种机制。
4.3 Front-Channel Logout
OP 在登出页面里嵌一堆隐藏 iframe,每个 iframe 的 src
指向各个 RP 在注册时填的
frontchannel_logout_uri:
<iframe src="https://app-a.example.com/oidc/logout?sid=xxx&iss=..."></iframe>
<iframe src="https://app-b.example.com/oidc/logout?sid=xxx&iss=..."></iframe>
<iframe src="https://app-c.example.com/oidc/logout?sid=xxx&iss=..."></iframe>每个 RP 在自己的 frontchannel_logout_uri
里清除自己的 session cookie。
优点:实现简单,不需要 RP 和 OP 之间建立 server-to-server 连接,跨云跨网络边界天然能工作。
缺点致命:
- 依赖用户浏览器同时能加载所有 iframe。任何一个 iframe 被广告拦截、网络抖动、域名证书过期都会让那个 RP 漏登出。
- 浏览器默认阻止第三方 cookie,RP 的 cookie 在 iframe 场景下经常拿不到,导致 logout 处理端识别不出用户。Safari 的 ITP、Firefox 的 ETP、Chrome 的 CHIPS 全都在收紧这条路。
- 用户关闭了 OP 登出页面(iframe 还没加载完)就彻底失败。
结论:2026 年不要把 front-channel 当作主方案,它只适合作为 back-channel 不可用时的降级。
4.4 Back-Channel Logout
OP 直接 server-to-server POST 一个 logout_token 到每个 RP
的 backchannel_logout_uri:
POST /oidc/backchannel-logout HTTP/1.1
Host: app-a.example.com
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ...
logout_token 是一个 JWT,必要字段:
{
"iss": "https://op.example.com",
"sub": "user-uuid",
"aud": "app-a-client-id",
"iat": 1713600000,
"jti": "b4e8f1...",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
},
"sid": "session-id"
}RP 收到后:
- 验证 JWT 签名(用 OP 的 JWKS)、
iss/aud/iat。 - 检查
eventsclaim 里有backchannel-logout。 - 禁止出现
nonce(规范要求)。 - 用
sid或sub定位本地 session,清除。 - 幂等处理
jti,防重放。 - 返回 HTTP 200(空 body)或 400(失败)。
Back-channel 的处理器:
func backchannelLogout(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
raw := r.FormValue("logout_token")
tok, err := jwt.Parse(raw, jwks.Keyfunc)
if err != nil || !tok.Valid {
http.Error(w, "invalid token", 400); return
}
claims := tok.Claims.(jwt.MapClaims)
if claims["iss"] != expectedIssuer {
http.Error(w, "bad iss", 400); return
}
if claims["aud"] != clientID {
http.Error(w, "bad aud", 400); return
}
if _, ok := claims["nonce"]; ok {
http.Error(w, "nonce not allowed", 400); return
}
events, _ := claims["events"].(map[string]interface{})
if _, ok := events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
http.Error(w, "missing event", 400); return
}
jti, _ := claims["jti"].(string)
if redis.SetNX(ctx, "logout_jti:"+jti, "1", 10*time.Minute).Val() == false {
w.WriteHeader(200); return
}
sid, _ := claims["sid"].(string)
sub, _ := claims["sub"].(string)
if sid != "" {
destroySessionBySID(sid)
} else if sub != "" {
destroyAllSessionsForUser(sub)
}
w.WriteHeader(200)
}优点:不依赖浏览器,server 间可靠传输,能做重试和死信队列。
缺点:
- 每个 RP 必须有公网可达的
backchannel_logout_uri(内网产品就得搭反向代理或消息队列中转)。 - OP 要对所有 RP 做 N 个 HTTP 请求,RP 多时延迟高;通常异步放队列。
- 不能清除浏览器 cookie(这本来就是 server-to-server),所以用户下次打开那个 RP 页面可能 session 还在客户端缓存里一小段时间。配合 cookie 短 TTL + server session 吊销即可。
4.5 三种模式对比
| 维度 | RP-Initiated | Front-Channel | Back-Channel |
|---|---|---|---|
| 触发方 | 用户点击 | OP 浏览器 | OP 服务器 |
| 传输 | 浏览器重定向 | iframe 加载 | server-to-server POST |
| 可靠性 | 高 | 低(浏览器依赖) | 高(可重试) |
| 跨域 cookie | 不需要 | 常被浏览器拦截 | 不涉及 |
| 延迟 | 毫秒级 | 秒级,取决于网络 | 秒级,取决于 RP 处理 |
| 需要 RP 公网可达 | 不需要 | 不需要 | 需要 |
| OIDC 规范成熟度 | Stable | Stable 但问题多 | Stable,推荐 |
2026 年推荐配置:RP-Initiated 作为入口,Back-Channel 作为主传播,Front-Channel 只在 back-channel 无法部署时兜底。
五、OIDC Logout 端点与元数据
5.1 Discovery 暴露的端点
OP 的 /.well-known/openid-configuration
应该包含:
{
"issuer": "https://op.example.com",
"authorization_endpoint": "https://op.example.com/oauth2/authorize",
"token_endpoint": "https://op.example.com/oauth2/token",
"revocation_endpoint": "https://op.example.com/oauth2/revoke",
"introspection_endpoint": "https://op.example.com/oauth2/introspect",
"end_session_endpoint": "https://op.example.com/oidc/logout",
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"frontchannel_logout_supported": true,
"frontchannel_logout_session_supported": true
}backchannel_logout_session_supported 为 true
表示 logout_token 里会包含 sid,RP 可以精确定位
session 而不用按 sub 登出用户所有会话。
5.2 Client 注册时的关键字段
在 OP 注册 client 时必须登记:
{
"client_id": "app-a",
"redirect_uris": ["https://app-a.example.com/callback"],
"post_logout_redirect_uris": ["https://app-a.example.com/goodbye"],
"backchannel_logout_uri": "https://app-a.example.com/oidc/bc-logout",
"backchannel_logout_session_required": true,
"frontchannel_logout_uri": "https://app-a.example.com/oidc/fc-logout",
"frontchannel_logout_session_required": true
}5.3 /revoke 端点:RFC 7009
OAuth 2 Token Revocation(RFC 7009)定义了
revocation_endpoint,用于 RP 主动吊销单个
token(通常是 RT,AT 吊销意义不大因为 TTL 本来就短):
POST /oauth2/revoke HTTP/1.1
Host: op.example.com
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
token=eyJhbGc...&token_type_hint=refresh_token
成功返回 200(无 body),无论 token 是否存在都返回 200,防止探测攻击。失败只有 400 / 401 / 503。
服务端实现要注意:
token_type_hint是提示,如果 hint=refresh_token 但找不到,仍然要去找 access_token,只是查询顺序不同。- 即使 token 已经过期或已经不存在,也返回 200。
- 对 RT 吊销一般同时吊销整个 family(或至少吊销该 RT 链条上未使用的后代)。
5.4 /introspect 端点:RFC 7662
Token Introspection 让 RS 向 auth server 查询 token 状态:
POST /oauth2/introspect
Authorization: Basic base64(rs_id:rs_secret)
token=eyJhbGc...
→ 200 {"active": true, "scope": "read write", "exp": 1713605000, "sub": "u-123", ...}
→ 200 {"active": false}
这是从 JWT 退回类 session 的机制:每次请求都问 auth server 一次,牺牲性能换实时吊销。
工程选择:
- 纯 JWT 本地验证:延迟最低,吊销靠黑名单 + 短 TTL。
- 纯 introspection:吊销最实时,但 auth server 成瓶颈,每 RS 要跑独立缓存。
- 混合:默认 JWT 本地验证 + 黑名单;高敏感接口(转账、改密)再打一次 introspection 确认。
推荐混合路线。introspection 结果可以在 RS 本地缓存 5-30 秒,仍然比纯 JWT 的 TTL 短得多。
六、批量吊销场景
6.1 改密触发全设备吊销
最常见的场景。用户改密成功后,旧的所有 session 必须失效——因为改密往往意味着用户怀疑密码泄露。
实现思路是给用户维度加一个「吊销基线」:
user_rev:{uid} = <timestamp> # 用户所有 token 在这个时间戳之前签发的全部无效
签发时把 iat 或一个新字段
session_start 写进 JWT。RS 验证时除了查
revoked:{jti},再查
user_rev:{uid},如果 token 的 iat <
user_rev[uid],视为已吊销。
func validateToken(ctx context.Context, tok *jwt.Token) error {
if revoked(ctx, tok.Claims.JTI) { return ErrRevoked }
baseline, err := redis.Get(ctx, "user_rev:"+tok.Claims.Sub).Int64()
if err == nil && tok.Claims.IAT < baseline {
return ErrRevoked
}
return nil
}user_rev:{uid} 的 TTL 应该 = AT
最长可能寿命(否则 token 过期后这个 key 不再需要保留)。
6.2 账号封禁
风控判定账号为恶意,封禁 = 改密 + 永久禁止登录。实现:
- 同上设置
user_rev:{uid} = now()。 - 在用户表里标记
status = banned。 - auth server 的 /token 刷新路径先查 status,banned 直接返回 invalid_grant。
- 登录路径同样拒绝。
这里 user_rev 的 TTL 要设为 AT 最长
TTL(比如 15 分钟),15 分钟后即使这个 key
被淘汰也无所谓,因为所有 token 都已过期。
6.3 设备丢失与 device-bound token
用户手机丢了,要把那台设备上的 session 全部吊销,但保留其他设备(电脑、平板)。
前提是签发时把 device_id 记入 token,并在服务端维护
user_sessions:{uid}:{device_id} = {fid, jtis...}
的索引:
登录:
签发 AT: {sub, device_id, jti, fid}
存 device_sessions:{uid}:{did} → fid=F1
吊销设备:
DEL device_sessions:{uid}:{did}
SET family:F1.status = revoked
SET revoked:{F1}:* 全部 jti
PUBLISH revoke {type=device, uid, did}
更强的做法是 device-bound token(DPoP 或 mTLS):AT 在签发时绑定设备的公钥,RS 验签时要求客户端证明私钥持有。设备丢了但攻击者没拿到私钥(例如私钥存在 TEE / Secure Enclave),token 天然失效。这部分展开够另一篇文章。
6.4 租户注销(tenant offboarding)
B2B SaaS 场景,一个企业客户解约,要吊销该租户下所有员工的所有 token。
数据结构:
tenant_sessions:{tid} → SET of (uid, fid)
吊销:
func revokeTenant(ctx context.Context, tid string) error {
entries, _ := redis.SMembers(ctx, "tenant_sessions:"+tid).Result()
pipe := redis.Pipeline()
now := time.Now().Unix()
for _, e := range entries {
uid, fid := splitEntry(e)
pipe.Set(ctx, "user_rev:"+uid, now, 24*time.Hour)
pipe.HSet(ctx, "family:"+fid, "status", "revoked")
}
pipe.Publish(ctx, "revoke", fmt.Sprintf(`{"type":"tenant","tid":"%s"}`, tid))
pipe.Del(ctx, "tenant_sessions:"+tid)
_, err := pipe.Exec(ctx)
return err
}条目数可能极大(一个企业 1 万员工 × 3 设备 = 3 万 session),要分批 pipeline,单批控制在 500-1000 条以内避免 Redis 阻塞。
6.5 吊销场景对比
| 场景 | 粒度 | 数据结构 | TTL 策略 | 是否需广播 |
|---|---|---|---|---|
| 单 token 吊销 | jti | revoked:{jti} |
AT 剩余寿命 | 是 |
| RT reuse | family | family:{fid}.status |
RT 剩余寿命 | 是 |
| 改密/登出所有设备 | user | user_rev:{uid} |
AT 最长 TTL | 是 |
| 封号 | user | 用户表 + user_rev |
AT 最长 TTL | 是 |
| 设备丢失 | device | device_sessions + family |
AT 最长 TTL | 是 |
| 租户注销 | tenant | 批量 user 吊销 | AT 最长 TTL | 是(分批) |
七、工程落地:Redis 键设计、Bloom filter、广播
7.1 Redis 键设计
规范化的 key 布局(prefix 可按环境加 env):
rt:{hash12(rt)} HASH {uid, fid, parent, used, iat, exp} EX=RT_TTL
family:{fid} HASH {uid, status, created_at, last_rt_jti} EX=RT_TTL
family_jtis:{fid} SET all AT jti issued in this family EX=RT_TTL
user_sessions:{uid} SET active fid list EX=RT_TTL
user_rev:{uid} STRING baseline timestamp EX=AT_MAX_TTL
revoked:{jti} STRING '1' EX=AT_remaining
device_sessions:{uid}:{did} HASH {fid, last_seen} EX=RT_TTL
tenant_sessions:{tid} SET {uid}:{fid} entries EX=RT_TTL
compromised_families SET fids marked reused EX=24h
logout_jti:{jti} STRING '1' (back-channel dedup) EX=10m
规模估算(1000 万 DAU,平均 2 session/用户):
rt:*:2 千万条,每条 ~200 字节 = 4 GB。revoked:*:假设日吊销 10 万、平均 TTL 10 分钟,稳态 ~70 条同时存在(太少可忽略)。user_rev:*:稀疏,一般百万级以内。
总内存 5-10 GB,单 Redis 集群分片 3-6 个即可。
7.2 hash 策略
RT 千万不要以明文做 key。用 hash12 表示取
SHA-256 前 12 字节 hex(24 字符),碰撞概率在 2^-96
量级足够。完整 SHA-256 也可以,只是 key 更长。
func hashRT(rt string) string {
h := sha256.Sum256([]byte(rt))
return hex.EncodeToString(h[:16])
}7.3 Bloom filter 快速路径
黑名单的绝对命中率极低(吊销事件稀疏),每请求都打一次 Redis 浪费。RS 本地维护 bloom filter:
- 本地 bloom 说「不存在」:100% 确定 token 未被吊销,不访问 Redis。
- 本地 bloom 说「可能存在」:再查一次 Redis 确认(付费路径)。
参数设计需要回答两个问题:预计元素数 n、可容忍误判率 p。
计算公式:
m (位数) = - n * ln(p) / (ln 2)^2
k (哈希数) = m / n * ln 2
假设:
- 每 RS 节点缓存最近 24 小时所有吊销 jti,预估 n = 10^6。
- 目标误判率 p = 10^-4(即 1 万次 miss 会有 1 次假阳性)。
代入:
m = -10^6 * ln(10^-4) / (ln 2)^2
= -10^6 * (-9.21) / 0.4805
≈ 1.917 * 10^7 位
≈ 2.4 MB
k = m / n * ln 2 ≈ 19.17 * 0.693 ≈ 13
结论:约 2.4 MB 内存、13 个哈希函数,能在 100 万吊销规模下把误判率压到 0.01%。假阳性只会多一次 Redis 查询,不会错放真实吊销。
实现选择:
- Go:
github.com/bits-and-blooms/bloom/v3 - Python:
pybloom-live - 滚动 bloom(应对时间窗口外过期):维护两个 filter(current / next),每小时切换并重新从 Redis 拉取过去 24h 吊销事件。
type RollingBloom struct {
current *bloom.BloomFilter
next *bloom.BloomFilter
mu sync.RWMutex
}
func (b *RollingBloom) Test(key string) bool {
b.mu.RLock(); defer b.mu.RUnlock()
return b.current.TestString(key) || b.next.TestString(key)
}
func (b *RollingBloom) Rotate(ctx context.Context) {
fresh := bloom.NewWithEstimates(1_000_000, 0.0001)
rehydrateFromRedis(ctx, fresh) // SCAN revoked:* and Add
b.mu.Lock()
b.current = b.next
b.next = fresh
b.mu.Unlock()
}7.4 中间件示例(Go)
func AuthMiddleware(jwks *JWKSet, bloom *RollingBloom, rdb *redis.Client) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" { http.Error(w, "no token", 401); return }
tok, err := jwt.Parse(raw, jwks.Keyfunc)
if err != nil || !tok.Valid { http.Error(w, "bad token", 401); return }
c := tok.Claims.(jwt.MapClaims)
if exp, _ := c["exp"].(float64); int64(exp) < time.Now().Unix() {
http.Error(w, "expired", 401); return
}
jti, _ := c["jti"].(string)
sub, _ := c["sub"].(string)
iat, _ := c["iat"].(float64)
if bloom.Test("jti:"+jti) {
if v, _ := rdb.Exists(r.Context(), "revoked:"+jti).Result(); v > 0 {
http.Error(w, "revoked", 401); return
}
}
if baseline, err := rdb.Get(r.Context(), "user_rev:"+sub).Int64(); err == nil {
if int64(iat) < baseline {
http.Error(w, "revoked", 401); return
}
}
ctx := context.WithValue(r.Context(), "claims", c)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}注意 user_rev:{sub}
的查询每次请求都发生,频繁用户会造成 Redis 热 key。优化:RS
本地缓存 user_rev 值 10-30 秒,过期再拉。
7.5 Pub/Sub 广播
Redis 的 PUBSUB 简单好用,但有两个坑:
- 消费者断线期间的消息会丢,重连后无法补消费。
- Redis 集群模式下 PUBSUB 是全集群广播,节点多时放大。
工程上推荐:
- 短期:Redis PUBSUB
做热路径通知,同时每个 RS 节点定期(每 60 秒)全量 SCAN
revoked:*刷新本地 bloom,作为兜底。 - 大规模:改用 Kafka / Pulsar 持久化 topic,配合 consumer group,断线不丢消息。
广播 payload 建议字段:
{
"v": 1,
"type": "jti|family|user|device|tenant",
"id": "...",
"not_before": 1713600000,
"reason": "password_change|admin|abuse|logout",
"emitted_at": 1713600001
}消费端幂等处理:记录 (type, id, not_before)
三元组的最近值,重复事件直接忽略。
7.6 Python 版刷新与 reuse detection
import hashlib, json, time, secrets
import redis
r = redis.Redis()
RT_TTL = 30 * 24 * 3600
AT_TTL = 15 * 60
def hash_rt(rt: str) -> str:
return hashlib.sha256(rt.encode()).hexdigest()[:24]
def mint_pair(uid: str, fid: str, parent_jti: str | None) -> dict:
at_jti = secrets.token_urlsafe(16)
rt = secrets.token_urlsafe(48)
at = build_jwt({"sub": uid, "jti": at_jti, "fid": fid,
"exp": int(time.time()) + AT_TTL,
"iat": int(time.time())})
r.hset(f"rt:{hash_rt(rt)}",
mapping={"uid": uid, "fid": fid, "parent": parent_jti or "",
"used": 0, "exp": int(time.time()) + RT_TTL})
r.expire(f"rt:{hash_rt(rt)}", RT_TTL)
r.sadd(f"family_jtis:{fid}", at_jti); r.expire(f"family_jtis:{fid}", RT_TTL)
return {"access_token": at, "refresh_token": rt}
def refresh(old_rt: str) -> dict:
key = f"rt:{hash_rt(old_rt)}"
entry = r.hgetall(key)
if not entry:
fid = extract_fid_from_jwt_or_log(old_rt)
abort_family(fid, reason="reuse_unknown_rt")
raise InvalidGrant("reuse detected")
if int(entry[b"used"]):
fid = entry[b"fid"].decode()
abort_family(fid, reason="reuse_used_rt")
raise InvalidGrant("reuse detected")
fid = entry[b"fid"].decode()
uid = entry[b"uid"].decode()
with r.pipeline() as p:
p.hset(key, "used", 1)
p.expire(key, 30)
new_pair = mint_pair(uid, fid, parent_jti=hash_rt(old_rt))
p.execute()
return new_pair
def abort_family(fid: str, reason: str):
r.hset(f"family:{fid}", mapping={"status": "compromised", "reason": reason})
for jti in r.smembers(f"family_jtis:{fid}"):
r.set(f"revoked:{jti.decode()}", "1", ex=AT_TTL)
r.publish("revoke", json.dumps({"type": "family", "id": fid,
"not_before": int(time.time()), "reason": reason}))八、Token Binding vs Token Introspection
8.1 问题本质
到此为止讨论的机制都无法解决一件事:如果 bearer token 被完整窃取,中间人或恶意客户端可以直接使用,系统看不出区别。黑名单只能在检测到异常后吊销,不能阻止首次被滥用。
两条增强路线:
- Token Introspection(RFC 7662):每次请求都问 auth server 确认 token 状态。成本高、延迟高,但实时性最强。
- Token Binding / Proof-of-Possession:token 签发时绑定客户端的某种私钥或 TLS 通道,使用时必须证明持有,窃取 token 本身无用。
8.2 Token Introspection 的工程权衡
| 维度 | 纯 JWT 本地验证 | 带黑名单 JWT | Introspection |
|---|---|---|---|
| 验证延迟 | <1ms | 1-3ms | 5-30ms |
| 实时吊销 | 只能等 exp | 秒级(广播延迟) | 即时 |
| auth server 依赖 | 无 | 弱(失败可降级) | 强(失败则拒绝请求) |
| auth server QPS | 仅签发时 | 仅签发/吊销 | 每请求 |
| 适用场景 | 普通 API | 大多数场景 | 高敏感操作 |
混合策略:普通接口走 JWT + 黑名单,高敏感接口(转账、改密、下载密钥)走 introspection。Introspection 响应可以本地缓存 5-30 秒降低 QPS。
8.3 Token Binding 现状
- Token Binding(RFC 8471):把 token 绑到 TLS 通道的客户端密钥。规范完整但浏览器支持停滞,Chrome 移除了实现,基本死掉。
- mTLS-bound tokens(RFC 8705):把 token 绑到 client mTLS 证书的 thumbprint。企业内网、金融开放 API(Open Banking)广泛采用。
- DPoP(RFC 9449):Demonstrating Proof
of Possession。客户端持有一个临时 EC key,每次请求携带一个新
JWT(DPoP proof)证明私钥持有。AT 的
cnf.jktclaim 里嵌入公钥 thumbprint。不依赖 TLS 栈,浏览器 / SPA 能用。
DPoP 示例请求:
GET /api/user HTTP/1.1
Authorization: DPoP eyJhbGci...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...
DPoP proof JWT 载荷:
{
"htm": "GET",
"htu": "https://api.example.com/user",
"iat": 1713600000,
"jti": "proof-unique-id",
"ath": "<base64url sha256 of access_token>"
}RS 验证:
- 检查 DPoP JWT 签名由 header 里
jwk的公钥签。 htm/htu匹配实际请求。iat在 ± 60 秒内。jti在 nonce store 里唯一(防重放)。- AT 的
cnf.jkt== sha256(jwk) base64url。 ath== sha256(AT) base64url。
DPoP 把 bearer token 从「持有即用」升级为「持有并证明」,token 泄露本身不够攻击者使用。代价是客户端要维护 EC 密钥、每请求多一次签名。
8.4 选型建议
| 场景 | 推荐 |
|---|---|
| 内网 B2B API | mTLS-bound |
| 公网浏览器 / SPA | DPoP(2026 年主流浏览器 Web Crypto API 支持 EC 签名) |
| 原生 App | DPoP,密钥存 Keystore / Secure Enclave |
| 高敏感操作 | DPoP + introspection 双保险 |
| 老系统改造 | bearer + 黑名单 + 短 TTL 组合已足够 |
九、工程坑点
9.1 广播消息丢失造成的吊销漏洞
Redis PUBSUB 消费者断线期间的消息永久丢失。如果某台 RS 断线 30 秒,期间有 10 个 token 被吊销,这台机器会继续放行这 10 个 token 到各自的 exp。
兜底:每个 RS 进程启动时全量 SCAN revoked:*
重建 bloom,运行期间每 60
秒增量刷新一次最近吊销事件。或者直接换 Kafka。
9.2 Clock Skew 与 not_before 语义
user_rev:{uid} 用了时间戳比较。如果 auth
server 的时钟比 RS 快 5 秒,刚签发的 token 的 iat 可能 <
RS 看到的 baseline,token 立刻失效。
做法:
- 所有服务强制 NTP / PTP 时间同步,监控漂移。
- baseline 用
now() - leeway(leeway 5-10 秒),给时钟漂移留空间。 - 比较时加小 leeway,比如
iat + 5 < baseline才判吊销。
9.3 Redis 单点故障导致「假吊销」或「假通过」
黑名单查 Redis 失败怎么办?两种降级策略:
- fail-open:Redis 挂了就放行(不查黑名单)。可用性高,但攻击者等 Redis 挂时发起攻击能绕过吊销。
- fail-closed:Redis 挂了就拒绝所有请求。安全,但业务全停。
现实选择:正常请求 fail-open(本地 bloom 已经过滤),高敏感操作 fail-closed。bloom filter 本身是 RS 本地的,Redis 挂了仍能工作,只是无法拿到最新吊销事件。
9.4 Refresh Token 并发刷新误杀 family
前面讲了 grace window。还有一个隐蔽场景:用户网络抖动,App 发起刷新请求后没收到响应就重试,第二个请求用的是同一个 RT。如果服务端没做 grace window,就会触发 reuse 把 family 杀掉。
grace window 务必实现,并建议设 30 秒以上。
9.5 Back-channel logout URL 不可达 / 返回 5xx
如果 RP 的 backchannel_logout_uri 宕机,OP
收到 5xx 或超时。OP 应该实现重试(指数退避,3-5
次),失败进死信队列告警,而不是直接放弃。
另一方向:RP 侧的 logout 处理器必须幂等。OP
可能因超时重发同一个 logout_token,RP 按 jti
去重。
9.6 id_token_hint 的过期问题
用户登录后使用 App 数周,id_token
早就过期。到了要登出的时候,RP 把过期的 id_token 当
id_token_hint 发给 OP,规范允许但要求 OP
只拿里面的 claims 做识别,不校验 exp。部分 OP 实现错误地校验
exp,导致用户登出失败。
客户端兜底:id_token 保存副本到登出时使用,exp 过期也不丢弃。
9.7 Bloom filter 的滚动窗口造成短暂假阴性
滚动 bloom 切换窗口时,current/next 切换那一瞬间,如果从 Redis SCAN 慢于切换,部分吊销事件会短暂不在任何 filter 里——bloom 说不存在,但 Redis 里明明有。
补救:切换用「先构建 next 完毕 → 原子替换」的顺序,保证任何时刻 current ∪ next 覆盖全部有效事件。如果构建 next 需要分钟级,应该提前启动。
9.8 Revoke endpoint 被用作 token 探测
攻击者拿到一串疑似 token,用 /revoke 端点探测是否有效:如果 /revoke 对不存在的 token 返回 404、对存在的返回 200,攻击者能区分。
修复:始终返回 200,无论 token 是否存在。RFC 7009 明确要求。
9.9 SLO 不能保证完全一致性
有些 RP 的 session 缓存在浏览器 sessionStorage 里,back-channel 登出只能清服务端 session cookie,不能清浏览器内存状态。用户可能在被登出后还能看到缓存的用户名(但下一次 API 请求会 401)。
这不是安全漏洞,但会造成困惑。前端要在 401 时彻底清 UI 状态、跳转登录。
十、落地建议
10.1 架构默认值
- access token:JWT,RS256,TTL 10 分钟,本地验签。
- refresh token:opaque string,存 Redis hash,TTL 30 天,每次刷新 rotation。
- family + reuse detection + grace window 30 秒:默认开启。
- 黑名单
revoked:{jti}TTL = AT 剩余寿命;user_rev:{uid}TTL = AT 最长 TTL。 - RS 本地 bloom filter,n=10^6 / p=10^-4,每 60 秒增量刷新。
- Redis PUBSUB 广播 + 每 60 秒全量兜底。规模大改 Kafka。
- OIDC logout:RP-Initiated 作为入口,back-channel 为主,front-channel 只做补充。
10.2 分阶段落地路径
| 阶段 | 目标 | 工作量 |
|---|---|---|
| 1. 基础 | 短 TTL + RT + 黑名单 Redis,RS 带中间件 | 1-2 人周 |
| 2. 安全 | Rotation + reuse detection + family + 广播 | 2-3 人周 |
| 3. 性能 | Bloom filter + 本地缓存 + 批量吊销优化 | 1-2 人周 |
| 4. SSO | end_session + back-channel logout 对接 | 2-4 人周(每个 RP) |
| 5. 强化 | DPoP / mTLS-bound + introspection 混合 | 3-6 人周 |
不要一上来就冲第 5 阶段,绝大多数系统 1-2 阶段就能扛住主要风险。
10.3 监控指标
- 吊销延迟:从 /revoke 调用到所有 RS 本地拒绝该 token 的 p99 时间(目标 < 5 秒)。
- reuse detection 触发率:每天每百万 rotation 的 reuse 次数,基线 < 5,突增预警。
- family_compromised 数量:被标记 compromised 的 family 每天数,突增 = 可能的大规模攻击。
- Redis 黑名单内存:关注稳态和峰值,异常膨胀可能是吊销率异常高。
- Bloom filter 假阳性实际率:对比本地 bloom miss 和 Redis 实际命中,长期跟踪。
- Back-channel logout 成功率:每 client 的 logout_token 投递成功率,低于 99% 应告警。
/tokenrefresh QPS:突增可能是客户端 bug 死循环。- Introspection 延迟 / QPS(如果启用)。
10.4 灾备预案
- 准备「全平台强制登出」开关:管理员一键把
user_rev:*全写成当前时间。极端场景下用。 - 黑名单持久化:Redis 开 AOF everysec,AOF 在多节点间通过 CRDT 或主从同步。
- Bloom filter rehydrate 手册:RS 重启后从 Redis/对象存储重建 bloom 的操作流程。
- Auth server 多活:/revoke 写路径要保证所有数据中心最终一致,推荐 Redis 跨地域双写 + Kafka 回放。
- 回滚预案:rotation 出 bug 会把所有 family 杀掉,要有功能开关快速禁用 reuse detection,让系统退化到「不安全但可用」模式,再修复。
10.5 Don’t / Do 清单
- Don’t:在 JWT payload 里塞长期不变的敏感信息(比如密码 hash、完整身份证)。
- Don’t:用 /revoke 返回区分存在与不存在。
- Don’t:Front-channel logout 作为唯一 SLO 机制。
- Don’t:对每个 API 请求都打 introspection。
- Don’t:在生产 Redis 上
KEYS *。 - Do:所有 opaque token 存 hash 不存明文。
- Do:family + reuse detection + grace window 一起上。
- Do:RS 本地 bloom + 共享黑名单混合。
- Do:back-channel logout 加重试和死信。
- Do:吊销延迟和 reuse 检出率作为核心 SLI。
十一、参考资料
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 6750 - Bearer Token Usage
- RFC 7009 - OAuth 2.0 Token Revocation
- RFC 7519 - JSON Web Token
- RFC 7662 - OAuth 2.0 Token Introspection
- RFC 8471 - Token Binding Protocol
- RFC 8705 - OAuth 2.0 Mutual-TLS Client Authentication
- RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- OpenID Connect Core 1.0
- OpenID Connect RP-Initiated Logout 1.0
- OpenID Connect Back-Channel Logout 1.0
- OpenID Connect Front-Channel Logout 1.0
- OAuth 2.0 Security Best Current Practice (draft-ietf-oauth-security-topics)
- Auth0 Blog - Refresh Token Rotation
- Okta - Refresh Token Rotation and Revocation
- Burton S.H. Bloom, “Space/Time Trade-offs in Hash Coding with Allowable Errors”, 1970.
- Broder, Mitzenmacher, “Network Applications of Bloom Filters: A Survey”.
- Keycloak - Logout and Session Invalidation
- 认证架构:从 Session 到 JWT 到 OIDC
- OAuth 2.0 Token 端点详解
- JWT、JWS、JWE、JWKS 一次讲透
下一篇:MFA、TOTP、WebAuthn、Passkey 工程实践
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】JWT、JWS、JWE、JWKS 一次讲透
JWT 是现代身份系统事实上的令牌格式,但围绕它的 JWS、JWE、JWK、JWKS 四个 RFC 常常被混为一谈。本文从标准归属、字段细节、算法选型、攻击面、密钥轮换到生产运维,把 JWT 相关的工程问题一次讲透
【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO
B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。
【身份与访问控制工程】Keycloak 工程拆解:Realm、Client、Flow 与扩展机制
从 Quarkus runtime、Infinispan 缓存、数据库 schema,到 Authentication Flow 引擎、SPI 扩展点、multi-site 部署与常见工程坑点,拆解 Keycloak 的真实工程形态与选型边界。
【身份与访问控制工程】CIAM 架构:面向 B2B / B2C SaaS 的身份平台
系统梳理 CIAM(Customer Identity and Access Management)的场景差异、数据模型、隐私合规与工程坑点,覆盖 B2C 社交登录、B2B 企业 SSO/SCIM、B2B2C 组织模型,以及全球多区域部署与选型建议。