Keycloak 的 aud 从哪来:IAM audience 生成链路与排错 | IDaaS Book
场景
- 后端资源服务、API 网关或服务网格报 audience 不匹配,把 token 解码后只看得到
"aud": "account",自己的 client ID 不在里面。 - 在 Keycloak 里加了 Audience mapper 之后 token 变了,但没想清楚改的是 ID token 还是 access token,也不确定这个 mapper 会影响多少客户端。
- 有人建议用
--oidc-extra-audience、verify_aud: false或「跳过 audience 校验」先让服务通起来——这些做法把校验边界撤掉了,不能当成修复。
本文不讨论某个下游产品的开关该怎么填,只回答一个前置问题:Keycloak 签发的 token 里,aud 这个字段到底是谁写进去的、什么时候写、写谁。 基线是当前稳定版 26.8.0(2026-10-01 发布),所有行为结论都能在对应 tag 的源码里逐行核对,文末列出文件与行号。
适用与不适用
| 场景 | 是否适用 | 说明 |
|---|---|---|
定位 aud 缺 client ID、多出不认识的 client ID | ✅ | 本文主场景:先还原生成链路,再决定改哪里 |
| 判断某个 Audience mapper 该勾「Add to access token」还是「Add to ID token」 | ✅ | 两个 token 的 aud 语义不同,见第 2、3 节 |
| 评估改共享 client scope 的影响面 | ✅ | 默认 mapper 挂在多个客户端共用的内置 scope 上,见第 4、8 节 |
用 audience 请求参数(RFC 8707 resource indicators)控制 aud | ⚠️ | 这是另一条通道,26.7.4 标 Not supported、26.8.0 转 Experimental,见 MCP audience 绑定 |
| 下游产品报错的日志该怎么读、该不该加白名单 | ⚠️ | 要先确认它校验的是哪种 token,见第 2 节末尾 |
| 只想让校验「先别报错」 | ❌ | 关掉 audience 校验等于让任何合法签发的 token 都被接受,属于认证边界退化 |
1. 三个字段先分清:iss、azp、aud
排错时最容易混的一件事:azp 里有你的 client ID,不代表 aud 里有。
iss:签发者,即 realm 的 issuer,由请求地址/hostname配置决定。azp(authorized party):这个 token 是发给哪个客户端的。Keycloak 在初始化 access token 时就把它写成请求方 client ID。aud(audience):谁被允许消费这个 token。它不是 Keycloak 按请求方自动填的,而是由 protocol mapper 产出的——这是后面所有问题的根源。
{
"iss": "https://sso.example.com/realms/demo",
"azp": "app-portal",
"aud": ["account"],
"sub": "8f1c…",
"scope": "openid profile email"
}上面这个形状(azp 是 app-portal,aud 只有 account)在默认配置下是预期的,不是配置错误。
2. aud 的生成链路
sequenceDiagram
participant C as 客户端(应用 / 网关)
participant AS as 授权端点
participant TE as Token 端点
participant M as Protocol Mapper 链
C->>AS: /authorize?client_id=app-portal&scope=openid profile email
Note over AS: 登录客户端 = app-portal<br/>生效作用域 = 客户端默认 scope + 请求的 optional scope
AS->>C: 授权码
C->>TE: /token(code + PKCE)
Note over TE: initToken():写 iss / sub / iat / azp=app-portal / sid / scope<br/>此时不写 aud
TE->>M: 遍历该客户端生效的全部 protocol mapper
M->>M: realm roles → realm_access.roles(access token)
M->>M: client roles → resource_access.*.roles(access token)
M->>M: audience resolve → 追加"用户持有 client 角色的 client"
M->>M: Audience mapper → 追加显式配置的 client ID / 自定义值
TE->>C: access_token(aud = 上述 mapper 的并集)
Note over TE: ID token 先被服务端固定为 aud=[登录客户端],<br/>mapper 只能 addAudience 追加
链路里有两个不对称,值得单独记:
access token:服务端完全不写 aud。 TokenManager.initToken() 在初始化 access token 时设置 iss、sub、iat、jti、typ、azp(token.issuedFor(client.getClientId()))、sid、scope 等字段,没有任何设置 aud 的语句(26.8.0 TokenManager.java 第 1105–1130 行,issuedFor 在第 1117 行)。所以一个 access token 的 aud 是否存在、包含什么,100% 取决于生效的 protocol mapper。如果没有任何 audience 类 mapper 命中,这个 token 里干脆没有 aud 字段(不是空数组)。
ID token:服务端先写死登录客户端。 构造 ID token 时执行的是 idToken.audience(client.getClientId())(TokenManager.java 第 1421 行),而 JsonWebToken.audience(String...) 的语义是赋值覆盖,addAudience(String) 才是追加去重(JsonWebToken.java 第 207–228 行)。因此 ID token 的 aud 默认就包含发起这次登录的 client ID,mapper 只能在后面追加,删不掉它。
一个常被忽略的推论:access token 的 aud 里默认没有请求方自己的 client ID。 如果你的下游是资源服务、网格 sidecar 或 API 网关(它们校验的是 access token),那它看不到自己的 client ID 是默认行为,必须显式补一个 Audience mapper——这也是「azp 明明对得上、校验却失败」的常见成因。这里还有一层容易被忽略的加固:Audience Resolve 在遍历时会主动跳过请求方自身(AudienceResolveProtocolMapper.setAudience() 中对 entry.getKey().equals(clientId) 直接 continue,26.8.0 第 145–149 行),所以「给自己发一个客户端角色、指望它写进 aud」这条路不存在——要么加 mapper,要么由别的客户端来调用它。
至于具体是哪种 token 在被校验,只有下游组件自己的代码能回答:oauth2-proxy 的 --provider=keycloak-oidc 在非刷新会话上校验 ID token、刷新后的会话在配置了 ValidateURL 时才去校验 access token(v7.15.5 providers/oidc.go 第 114–131 行),而它输出的 --pass-access-token 则是把 access token 交给你的后端。先确认校验对象,再决定改哪一侧,这比猜 mapper 快得多。
3. 三个会动 aud 的 mapper
| Mapper | provider ID | 写哪种 token | 写什么值 | 默认是否启用 |
|---|---|---|---|---|
| Audience Resolve | oidc-audience-resolve-mapper | access token + introspection | 用户至少持有一个 client 角色的所有 client 的 client ID | ✅ 在默认 roles scope 中 |
| Audience | oidc-audience-mapper | 按开关(id.token.claim 默认 false) | included.client.audience 指定的客户端(已删除/禁用的客户端会被跳过),或 included.custom.audience 的任意字符串 | ❌ 需手工添加 |
| (token exchange 委派) | oidc-client-delegation-audience 等 | access token | 委派目标 audience | ❌ 需 token-exchange-delegation 特性 + optional scope |
三者的分工边界:
- Audience Resolve 是「自动的、按角色推导的」:它的官方描述就是「把用户拥有至少一个 client 角色的所有 client 的 client_id 加进 aud」。它是让「客户端之间的互相调用」能被识别的机制,不是给你声明某个固定接收方用的。
- Audience 是「显式的、你说了算的」:
included.client.audience走的是客户端选择器(客户端被禁用或删除时静默跳过,不会报错),included.custom.audience接受任意字符串。注意它的id.token.claim默认值是false——源码把这一项显式改成默认false,因为 ID token 的aud已由服务端写入登录客户端。所以「给 access token 补 audience」时,要勾的是 Add to access token,不要顺手勾 ID token。 - Token exchange 的委派 audience 是独立特性,未开启时这些 scope 根本不会出现在 realm 里。
4. 为什么默认的 aud 里必然出现 account
这是「aud 只有 account」这个现象的真正解释,分三步:
roles是默认授予的 client scope。 新建 realm 时addRolesClientScope()会创建rolesscope 并把三个 mapper 装进去——realm roles、client roles、audience resolve,然后newRealm.addDefaultClientScope(rolesScope, true)把它设为默认(OIDCLoginProtocolFactory.java第 528–544 行)。也就是说只要客户端没有把它摘掉,Audience Resolve 就是生效的。- 默认角色里包含
account客户端的角色。 建 realm 时RealmManager会对AccountRoles.DEFAULT(=view-profile、manage-account)逐个执行realm.addToDefaultRoles(roleModel)(RealmManager.java第 491–494 行、AccountRoles.java第 36 行);而addToDefaultRoles()的语义是「把这个角色加成 realm 默认角色的复合角色」(RealmModel.java第 844–848 行),默认角色又自动授予每个新建用户。 - Audience Resolve 因此总能命中
account。 每个用户都在account客户端上持有 client 角色 →account被写进aud。
所以正确的心智模型是:aud: ["account"] 不是「Keycloak 默认给了一个错的 audience」,而是「Audience Resolve 按规则正确地算出了唯一一个命中项」。 用户若在别的客户端上也有 client 角色,那些 client ID 会一起出现——管理员的 token 里通常还能看到 realm-management,正是同一规则。
推论也很直接:要让你自己的 client ID 出现在 access token 的 aud 里,只有两条路——显式加 Audience mapper,或者让用户在该客户端上持有 client 角色(并接受 Audience Resolve 会把所有这类客户端都写进去)。 后者听起来省事,实际上会把 aud 越滚越大,不建议为了「让校验通过」去发角色。
5. 最小配置
给名为 app-portal 的客户端补一个只作用于 access token 的 audience。用 Admin REST API 更便于进流水线和评审(先取 admin token,再 POST):
KC=https://sso.example.com
# 1) 找到客户端 UUID
CID=$(curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC/admin/realms/demo/clients?clientId=app-portal" | jq -r '.[0].id')
# 2) 在客户端上加 Audience mapper(只进 access token 与 introspection)
curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
"$KC/admin/realms/demo/clients/$CID/protocol-mappers/models" -d '{
"name": "aud-backend-api",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "backend-api",
"id.token.claim": "false",
"access.token.claim": "true",
"introspection.token.claim": "true"
}
}'几个决定要写进评审记录:
- 挂在客户端上,还是挂到共享 client scope 上? 挂在客户端上只影响这一个客户端,是默认选择;挂到 realm 级共享 scope 会把改动扩散给所有引用该 scope 的客户端(第 8 节)。
id.token.claim保持false。 只有下游确实要求 ID token 里出现这个接收方时才打开;ID token 默认已含登录客户端。introspection.token.claim是否要显式写? 源码里它的缺省行为是跟随access.token.claim(未配置且access.token.claim=true时按true处理,OIDCAttributeMapperHelper.java第 439–448 行),所以不写通常也是生效的;显式写出来是为了避免以后有人复制这段配置时误判。- 用了 lightweight access token 的客户端必须勾
lightweight.claim。 一旦该客户端签发轻量 access token,mapper 的取值会切换到lightweight.claim,不再回退看access.token.claim(AbstractOIDCProtocolMapper.transformAccessToken()第 89–95 行是三元选择,不是「或」)。这是往返类场景最容易漏的一条:token exchange 拿不到目标 audience,源头往往在这里。lightweight.claim严格等于字符串"true"才算打开。 - 同理,只勾了 ID token 的 mapper 会连
/userinfo一起影响:userinfo.token.claim未配置时按「跟随id.token.claim」处理(同文件第 428–437 行)。想只在 ID token 里出现、不进 userinfo,必须显式把 userinfo 设为false。
6. 验证:不登录也能看 claim
Admin REST API 提供了「按 scope 生成示例 token」的端点,可以在不跑一遍浏览器登录的情况下看 mapper 的结果:
# 示例 access token(claims 结构)
curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC/admin/realms/demo/clients/$CID/evaluate-scopes/generate-example-access-token?userId=<user-uuid>" | jq '{aud, azp, scope}'
# 示例 ID token / userinfo
curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC/admin/realms/demo/clients/$CID/evaluate-scopes/generate-example-id-token?userId=<user-uuid>" | jq .aud
curl -sS -H "Authorization: Bearer $TOKEN" \
"$KC/admin/realms/demo/clients/$CID/evaluate-scopes/generate-example-userinfo?userId=<user-uuid>" | jq .注意两点:这三个端点返回的是服务端模拟生成的 token 结构(claims JSON),不是可签名的 JWT 字符串,所以不能拿它去替实验签或塞给下游当凭证(接口定义见 26.8.0 ClientScopeEvaluateResource.java);scope 查询参数用来模拟「客户端在授权请求里带了 scope=...」的情形,验证 optional scope 时必须带上它。
真机验证时,用新签发的 token 解码确认 aud,不要让已登录的会话「顺便」验证——mapper 只影响后续签发的 token,浏览器里已有的 Cookie/会话不会补写新的 aud。解码只用于看 claim,签名、iss、exp 的校验仍由下游库完成。
7. 常见错误对照表
| 现象 | 先看什么 | 根因 | 处理 |
|---|---|---|---|
解码后 aud 只有 ["account"] | 用上面三个端点分别生成 access token 与 ID token 对比 | access token 的 aud 全由 mapper 产出,Audience Resolve 只命中 account | 加 Audience mapper(第 5 节);这是默认行为,不是 bug |
azp 是自己的 client ID,但下游说 audience 不对 | 确认下游校验的是哪种 token | access token 的 aud 与 azp 是两个独立字段 | 按 access token 补 audience,不要指望 azp 兜底 |
后端拿到 access token 后 aud 里没有自己 | 检查该客户端是否签发轻量 access token | 轻量 token 只读 lightweight.claim,不回退 access.token.claim | 勾上 Add to lightweight access token |
只给 ID token 开了 mapper,/userinfo 里也冒出来 | 看 mapper 的 userinfo 开关是否为空 | userinfo.token.claim 缺省跟随 id.token.claim | 显式设为 false |
| 加了 Audience mapper,用户重新登录还是老样子 | 确认抓的是新签发的 token | mapper 不影响已签发 token 与既有会话 | 重新登录/重新取 token 再验 |
aud 里 client ID 越加越多 | 数一下用户持有的 client 角色 | Audience Resolve 会写入所有命中客户端,包括内置 account | 用显式 Audience mapper 收敛,而不是靠角色推导 |
access token 的 scope 里看不到 roles | 看该 client scope 的 Include in token scope | 内置 roles/web-origins/acr/basic 的该开关是 false(OIDCLoginProtocolFactory.java 第 535、558、604、628 行),只有 profile/email/address/phone 等为 true | 用 realm_access.roles 判断权限,不要把 scope 当角色清单 |
用 audience 请求参数指定接收方无效 | 看版本与特性开关 | RFC 8707 在 26.7.4 标 Not supported、26.8.0 才转 Experimental,且语义是过滤而非追加 | 见 MCP authorization server 一文 |
有人让你加 --oidc-extra-audience 或关掉 audience 校验 | 问清它改的是谁的白名单 | 白名单只放宽本地校验,不修改 Keycloak 的 token | 先按第 5 节修上游;确需临时放宽要有失效时间 |
8. 影响面与回滚
影响面的判断只看一个问题:这个 mapper 挂在客户端上,还是挂在共享 client scope 上。
- 客户端自己的 mapper:影响面 = 这 1 个客户端,回滚 = 删除该 mapper。
- 共享 client scope 上的 mapper:影响面 = 所有引用该 scope 的客户端。内置的
roles、profile、email、web-origins、acr、basic都是realm 级对象,改一次就是全局;想给单个客户端定制,正确做法是新建一个客户端专用 scope,而不是改内置 scope。 - 改共享 scope 之前先导出它的当前定义(
GET /admin/realms/{realm}/client-scopes/{id}),改动后用同一份 JSONPUT回去即可回滚;如果 realm 已经用 keycloak-config-cli 声明式管理,改动要走仓库评审,手工改会被下一次导入覆盖。 - 回滚顺序建议:先删/还原 mapper,再让受影响的客户端重新登录取新 token,最后清理下游的临时白名单参数。不要把「关掉下游 audience 校验」当成回滚手段——那是另一个变更,且会长期留在配置里。
- 已经签发的 token 无法撤回
aud;如果这次改动引入了一个过宽的接收方,缩短 access token 生命周期比逐个撤 token 更现实。
常见问题(FAQ)
Audience Resolve 能不能删掉?
能,但要想清楚后果:它挂在默认 roles scope 上,删掉不只是少一个 aud 值——凡是靠「用户有该客户端角色 → 该客户端出现在 aud」实现的服务间授权判断都会失效。如果只是想收窄 aud,更稳的做法是保留 scope、把该客户端从默认 scope 里摘掉(改成 optional),或者在客户端策略层用 full-scope-disabled 一类的 executor 统一收敛(26.8.0 起 Full Scope Allowed 已弃用,迁移口径见
26.8.0 升级排查)。
为什么 ID token 的 aud 里看不到资源服务的 client ID?
因为 ID token 的 aud 语义是「这份身份断言发给谁」,服务端把它固定为登录客户端。资源服务的 audience 属于 access token 的范畴——要它出现在 access token 的 aud 里,就按第 5 节加 Audience mapper 并勾 access token;把它写进 ID token 只会让「身份断言」和「授权凭证」的边界变模糊,参见
OAuth 2.0 与 OIDC 的边界。
同一个 client ID 出现在多个 mapper 的输出里会怎样?
aud 走的是 addAudience(),追加时按字符串去重,所以重复不会产生重复项。真正需要小心的是同名普通 claim:非 multivalued 的 mapper 之间是同名覆盖(后执行者生效),multivalued 才会合并去重(JsonUtils.mapClaim() 第 96–131 行)。aud 不受这个问题影响,但如果你的自定义 mapper 用了别的 claim 名,就要按这个规则判断冲突结果。
改完 mapper 要重启 Keycloak 吗?
不需要。protocol mapper 是配置数据,改完对后续签发的 token 立即生效,不用重启、也不用 kc.sh build(只有自定义 SPI provider 才涉及构建,见
SPI 扩展的生产交付)。集群里的其它节点会通过 realm 缓存同步看到这次改动,但要确认缓存同步正常,否则会出现「部分节点生效」的假象。
主要来源
- Keycloak 26.8.0 源码(逐条核对,标注行号):
TokenManager.java:initToken()初始化 access token 只写azp(第 1105–1130 行,无aud)、AccessTokenResponseBuilder.generateIDToken()的idToken.audience(client.getClientId())(第 1421 行)、restrictRequestedAudience()对audience参数的过滤语义(第 1575–1582 行)JsonWebToken.java:audience字段无默认值(第 59 行)、audience()为覆盖赋值、addAudience()为追加去重(第 207–228 行)OIDCLoginProtocolFactory.java:addRolesClientScope()装入realm roles/client roles/audience resolve并设为默认 scope(第 528–544 行)、各内置 scope 的 Include in token scope 取值(第 443、464、535、558、604、628 行)、新 realm 的默认/optional scope 清单(第 437–524 行)AudienceProtocolMapper.java:included.client.audience/included.custom.audience、setClaim()用addAudience()、被禁用或删除的客户端会被跳过、id.token.claim默认被改为falseAudienceResolveProtocolMapper.java:transformAccessToken()的轻量 token 分支与 help text、setAudience()用RoleResolveUtil.getAllResolvedClientRoles()逐个追加并跳过请求方自身(第 142–155 行,跳过判断在第 145–149 行)OIDCAttributeMapperHelper.java:配置键常量(第 59–82 行,含lightweight.claim第 78 行)、includeInUserInfo()跟随id.token.claim(第 428–437 行)、includeInIntrospection()跟随access.token.claim(第 439–448 行)、includeInLightweightAccessToken()严格判"true"(第 450–452 行)AbstractOIDCProtocolMapper.java:transformAccessToken()在轻量 token 下切换到lightweight.claim(第 89–99 行)RealmManager.java+AccountRoles.java:AccountRoles.DEFAULT = {view-profile, manage-account}(第 36 行)被realm.addToDefaultRoles()加入默认角色(第 491–494 行)RealmModel.addToDefaultRoles():语义为「加为 realm 默认角色的复合角色」(第 844–848 行)JsonUtils.mapClaim():同名 claim 的覆盖与 multivalued 合并(第 96–131 行)
- Keycloak Admin REST API:
ClientScopeEvaluateResource(evaluate-scopes/generate-example-access-token、-id-token、-userinfo三个端点,返回类型分别是AccessToken/IDToken/ claims map,查询参数为scope、userId、audience;audience只对支持该参数的 grant 有意义,官方 javadoc 明确要求其余场景传null) - Keycloak Server Administration Guide(
Client scopes、Protocol Mappers章节,术语与 Admin Console 入口): https://www.keycloak.org/docs/latest/server_admin/ - oauth2-proxy v7.15.5 源码:
providers/keycloak.go(keycloakprovider 的ValidateSession校验 access token,第 118–121 行)、providers/oidc.go(keycloak-oidc走 OIDC provider:Refreshed会话才校验 access token,否则校验 ID token,第 114–144 行,ID token 校验在第 129 行)、providers/internal_util.go(validateToken()只是向 ValidateURL 发一次带 Bearer 的 GET,不做 JWT audience 校验,第 49–81 行)