场景

  • 后端资源服务、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

Mapperprovider ID写哪种 token写什么值默认是否启用
Audience Resolveoidc-audience-resolve-mapperaccess token + introspection用户至少持有一个 client 角色的所有 client 的 client ID✅ 在默认 roles scope 中
Audienceoidc-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」这个现象的真正解释,分三步:

  1. roles 是默认授予的 client scope。 新建 realm 时 addRolesClientScope() 会创建 roles scope 并把三个 mapper 装进去——realm roles、client roles、audience resolve,然后 newRealm.addDefaultClientScope(rolesScope, true) 把它设为默认(OIDCLoginProtocolFactory.java 第 528–544 行)。也就是说只要客户端没有把它摘掉,Audience Resolve 就是生效的。
  2. 默认角色里包含 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 行),默认角色又自动授予每个新建用户。
  3. 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 不对确认下游校验的是哪种 tokenaccess 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,用户重新登录还是老样子确认抓的是新签发的 tokenmapper 不影响已签发 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}),改动后用同一份 JSON PUT 回去即可回滚;如果 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 默认被改为 false
    • AudienceResolveProtocolMapper.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(keycloak provider 的 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 行)