先看结论与判断条件
- 解码令牌、验证签名、判断协议有效与执行业务授权是四个不同步骤,不能压成一个客户端布尔值。
- 客户端可用 exp、iss、aud 或 scope 做界面预检和减少无效请求,但服务器必须独立验证并以自身状态授权。
- 最值得进入 VMP 的是声明到本地状态的有限映射、密钥证明调用编排和关键失败路径,而不是整个 JOSE 库。
- 原生 App 是公共客户端,不能把打包进 APK 的 client secret 当作可靠机密;授权码流程应采用外部 user-agent 与 PKCE。
- DPoP 与 Android Keystore 可以降低令牌被盗后的部分滥用风险,但不能证明设备可信或替代服务器撤销、限权与重放控制。
- 发布门禁要验证篡改声明不会解锁本地敏感动作,并确认所有受保护资源仍由服务器返回明确拒绝或允许。
先定结论:客户端只做预检,资源服务器拥有最终授权权力
JWT 或其他结构化 access token 在客户端可被解码并展示声明,但解码只是把文本转换成字段。真正的有效性至少还包含签名、issuer、audience、时间、token 类型和当前策略验证;业务授权还要结合账户状态、资源所有权、scope、撤销和风险控制。移动端任何本地结果都不应成为服务器放行依据。
VMP 范围应围绕客户端确实必须执行的有限职责:严格解析预期结构、把声明映射成非敏感界面状态、编排 Keystore 或 DPoP 密钥调用、在异常时清除缓存并回到登录。它可以增加修改这些控制流的成本,却不能把客户端本地的 isAdmin、isPremium 或 tokenValid 变量变成可信授权事实。
最危险的设计是先在客户端解析 role 或 scope,再决定是否发送高权限请求,而服务器只相信路径已经被界面隐藏。攻击者可直接构造请求或修改本地分支。正确架构允许客户端为了体验隐藏按钮,但每个资源端点仍根据已验证 token 和数据库状态做对象级授权,拒绝结果由服务器产生。
| 层 | 客户端可做 | 服务器必须做 | VMP 边界 |
|---|---|---|---|
| 结构解码 | 检查段数与字段类型 | 拒绝畸形输入 | 可保护失败分支 |
| 密码学验证 | 按需做本地证明校验 | 验证签名和允许算法 | 不替代权威库 |
| 协议校验 | 用 exp 做体验预检 | 核对 iss、aud、exp、类型 | 保护有限映射 |
| 业务授权 | 控制界面展示 | 核对 scope、对象与状态 | 必须留在服务端 |
| 撤销与风险 | 收到错误后清缓存 | 查询撤销与异常策略 | 客户端无最终状态 |
| 重放控制 | 产生密钥证明 | 验证唯一性与上下文 | 保护调用编排 |
先拆开解码、验证、协议校验和业务授权
很多客户端库提供 decode 方法,它通常不验证签名。即使使用 verify,调用者也必须固定允许算法、密钥来源、issuer、audience、token 类型和时间策略。把 decode 成功、签名成功或 exp 尚未到期直接命名为 authorized,会让后续代码误用一个信息不足的结果。
建议使用不同类型表达各阶段。DecodedClaims 只能用于诊断和本地展示;LocallyPrecheckedToken 表示格式与时间预检通过;ServerSession 表示最近一次服务器确认的会话状态,但仍不能作为离线高权限依据。命名应迫使调用者看到证据等级,而不是把所有结果折叠成 true 或 false。
异常路径也要分层。格式错误可以立即丢弃,未知 issuer 或 audience 由服务器统一拒绝,网络失败不等于 token 有效或无效,401 与 403 需要分别触发重新认证或权限提示。客户端不得在验证服务不可达时默认放行,也不能为维持体验把过期字段改写到未来。
| 观察 | 可用于 | 不能用于 | 失败处置 |
|---|---|---|---|
| JWT 可解码 | 展示非敏感账号信息 | 证明签名有效 | 畸形则清除 |
| exp 未到期 | 决定是否提前刷新 | 证明未撤销 | 临界值向服务器确认 |
| scope 含某值 | 隐藏或显示入口 | 放行资源操作 | 等待服务器响应 |
| 签名本地通过 | 有限离线预检 | 证明对象级权限 | 按策略重新认证 |
| 服务器 401 | 刷新或登录 | 猜测具体安全原因 | 清理会话状态 |
| 服务器 403 | 提示无权限 | 自动扩大 scope | 保持拒绝状态 |
RFC 9068 的声明由资源服务器验证,不是客户端自我授权
RFC 9068 的 JWT access token profile 描述 issuer、subject、audience、expiry、client 标识和 scope 等声明,资源服务器用它们判断 token 是否为自己签发或可接受。客户端可以读取这些字段改善交互,但不能自行决定 issuer 是否受信、audience 是否包含目标资源或 scope 是否仍有效。
audience 尤其不能省略。一个为资源 A 签发且签名正确的 token,不应因为客户端能解码就发送给资源 B。客户端可根据配置选择目标,资源服务器仍必须检查自己的 audience。VMP 可以保护目标映射和错误分支,却无法阻止外部调用者绕开 App 直接向 API 发请求。
scope 表示授权范围的一部分,不等于业务对象权限。read:invoice 不能说明任意 invoice 都属于当前 subject,admin 字符串也不应绕过租户状态和风控策略。服务器先验证 token,再根据主体、资源和当前数据库做决策;客户端 scope 只适合作为界面预览和请求规划。
| 声明 | 客户端预检 | 服务器验证 | 禁止结论 |
|---|---|---|---|
| iss | 选择已知环境 | 匹配允许签发者 | 看到 URL 就可信 |
| aud | 选择目标 API | 包含当前资源 | 可跨资源复用 |
| exp | 提前刷新提示 | 按服务器时间拒绝 | 未撤销或未重放 |
| client_id | 诊断会话来源 | 核对授权上下文 | 代表用户身份 |
| scope | 界面入口提示 | 端点与操作授权 | 拥有任意对象 |
| sub | 显示账号线索 | 绑定主体与资源 | 客户端可改身份 |
原生 App 是公共客户端,不能用 APK 内 secret 建立信任
RFC 8252 将原生 App 视为公共客户端,因为分发到用户设备的二进制无法可靠保存共享 client secret。把 secret 字符串藏进 VMP、资源、原生库或动态配置可以增加提取成本,但不能让授权服务器把它当作只有官方 App 才知道的机密,也不应据此放宽 token 签发策略。
原生授权流程应使用外部 user-agent 和 PKCE,将授权请求与回调交换绑定。VMP 可保护 code verifier 生命周期、状态参数校验和回调错误处理的客户端实现,但授权服务器仍要验证 verifier、redirect URI、授权码单次使用和客户端类型。保护工具不能替代协议控制。
本地深链和回调处理属于输入边界。客户端要拒绝缺失 state、重复回调、未知 scheme 或不符合预期来源的结果,并避免把授权码写入日志。是否颁发 access token 始终由授权服务器决定;客户端发现格式正常只说明可继续交换,不说明用户已经授权某项业务操作。
DPoP 与 Keystore 降低滥用风险,但不创造设备可信
RFC 9449 的 DPoP 把 access token 与客户端持有密钥关联,并让请求证明携带方法、URI、时间和唯一标识。客户端可在 Android Keystore 中生成或保存不可导出的密钥句柄,再由受保护编排代码构造证明。这能降低单纯复制 bearer token 后的重放价值,但仍存在应用内调用被驱动的风险。
Android Keystore 可限制密钥算法、用途和用户认证条件,并在设备支持时使用硬件保护。VMP 不应复制原始私钥,而应保护 key alias 选择、证明输入组装、异常策略和轮换状态。API 调用成功也不能外推所有设备具有相同安全级别,需要真实设备和 KeyInfo 证据。
资源服务器必须验证 DPoP proof 的签名、目标方法与 URI、时间、唯一标识以及 access token 绑定,并执行自己的重放状态和授权。DPoP 不能证明设备没有被控制,Keystore 不能保护交给业务函数后的明文,VMP 也不能替代短有效期、撤销、限速和异常检测。
VMP 只覆盖稳定的小方法,不包住整个 JOSE 与网络栈
优先候选是字段到本地展示状态的白名单映射、token 类型分派、Keystore 证明调用编排、刷新失败清理和禁止降级分支。它们输入输出有限,泄露后会降低篡改成本,而且可用固定测试向量验证。保护规则应匹配准确方法签名,避免把同名重载或生成代码误纳入。
成熟 JOSE 库、Base64URL 解码、JSON 解析、HTTP 客户端、TLS、系统浏览器和回调框架通常保持在 VMP 外。整体虚拟化会放大兼容和性能风险,却不改变服务器是否验证签名与授权。依赖库应通过版本锁定、供应链审查和标准测试向量治理,而不是依赖实现不可见。
方法切片还要检查旁路。若另一个代码路径能直接写入 premium=true、跳过服务器响应或在 401 后保留敏感缓存,保护主解析器没有形成完整边界。发布评审应结合调用图、本地状态写入点、服务端拒绝用例和日志脱敏检查,但不能把静态覆盖写成运行时已不可绕过。
| 方法类型 | 默认建议 | 主要风险 | 验证重点 |
|---|---|---|---|
| 声明白名单映射 | 优先纳入 | 角色状态被篡改 | 未知字段失败 |
| 密钥证明编排 | 优先纳入 | 别名与输入被替换 | 目标和时间绑定 |
| 失败清理分支 | 优先纳入 | 旧状态继续使用 | 401 后缓存为空 |
| JOSE 验证库 | 通常不纳入 | 兼容与算法实现 | 固定允许算法 |
| JSON 与 Base64URL | 通常不纳入 | 格式和边界输入 | 标准测试向量 |
| HTTP 与浏览器 | 不整体纳入 | 系统耦合较高 | 回调与 TLS 配置 |
本地缓存与过期预检只能改善体验,不能作为离线授权
客户端可以缓存少量非敏感展示信息和 token 到期时间,用于提前刷新或显示重新登录提示。缓存记录应绑定 issuer、audience、subject、客户端版本与获取时间,敏感 token 使用平台安全存储并限制日志。即便缓存字段看起来完整,也不能在服务器不可达时自动放行购买、管理或数据导出。
时钟偏差需要明确策略。客户端 exp 预检应保守地提前刷新,最终过期判断使用服务器时间与协议允许的有限偏差。用户修改设备时间时,客户端最多改变界面提示,资源服务器仍会拒绝不满足时间条件的 token。VMP 保护时间分支不能把不可信本地时钟变成权威时钟。
清理策略要覆盖退出登录、刷新失败、issuer 变化、应用数据迁移和服务器撤销响应。本地展示状态从缓存重建时默认最小权限,等待服务器确认后再显示受保护入口。相邻的离线许可属于另一套信任与有效期模型,不能用 access token 解析规则直接替代。
用公开声明样例生成授权边界表,不验证真实 token
下面的 Python 示例读取一个不含真实凭据的 JSON claims 文件,检查根对象、字段类型、必需声明和未知高风险字段,再输出三类结果:客户端可用于预检的字段、必须由服务器确认的字段,以及明确禁止从本地形成的授权结论。它不读取私钥、不联网,也不尝试绕过或伪造签名。
示例故意不接收完整 JWT,因为把 payload 解码出来很容易被误解为验证完成。生产代码应把标准 JOSE 库的密码学验证结果与声明对象分开,并固定允许算法、issuer、audience 和 token 类型。该脚本只帮助代码评审定位职责边界,任何输出都不能用于资源服务器放行。
输入派生的类型或边界问题会以非零状态失败,流水线可保存原始样例、输出和工具摘要。通过表示声明满足这份教学规则,不表示 token 签名正确、未撤销、未重放或拥有任何对象权限。真实验收还要向测试资源发送请求并核对服务器拒绝矩阵。
import json
import sys
from pathlib import Path
CLIENT_PREFLIGHT = {"iss", "aud", "exp", "client_id", "scope", "sub"}
+SERVER_REQUIRED = {"signature", "issuer_policy", "audience_policy", "revocation", "object_acl", "replay_state"}
+FORBIDDEN_LOCAL_CONCLUSIONS = {"is_admin", "owns_resource", "payment_allowed", "token_authorized"}
+
+if len(sys.argv) != 2:
+ raise SystemExit("usage: classify_token_claims.py public-claims.json")
+claims_path = Path(sys.argv[1]).resolve()
+if not claims_path.is_file():
+ raise SystemExit(f"claims file missing: {claims_path.name}")
+with claims_path.open("r", encoding="utf-8") as stream:
+ claims = json.load(stream)
+if not isinstance(claims, dict):
+ raise SystemExit("claims root must be a JSON object")
+
+required = {"iss", "aud", "exp", "client_id", "scope", "sub"}
+missing = sorted(required - set(claims))
+if missing:
+ raise SystemExit(f"missing required claims: {missing}")
+if not isinstance(claims["iss"], str) or not claims["iss"].startswith("https://"):
+ raise SystemExit("iss must be a non-empty HTTPS identifier")
+if not isinstance(claims["aud"], (str, list)):
+ raise SystemExit("aud must be a string or string array")
+if isinstance(claims["aud"], list) and not all(isinstance(item, str) for item in claims["aud"]):
+ raise SystemExit("every aud entry must be a string")
+if not isinstance(claims["exp"], int) or isinstance(claims["exp"], bool):
+ raise SystemExit("exp must be an integer timestamp")
+if not isinstance(claims["scope"], (str, list)):
+ raise SystemExit("scope must be a string or string array")
+
+dangerous = sorted(FORBIDDEN_LOCAL_CONCLUSIONS & set(claims))
+if dangerous:
+ raise SystemExit(f"claims attempt to carry local authorization conclusions: {dangerous}")
+client_view = {name: claims[name] for name in sorted(CLIENT_PREFLIGHT) if name in claims}
+report = {
+ "client_preflight_only": client_view,
+ "server_must_confirm": sorted(SERVER_REQUIRED),
+ "never_conclude_locally": sorted(FORBIDDEN_LOCAL_CONCLUSIONS),
+ "boundary": "decoded claims are not a server authorization decision",
+}
+print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))发布门禁要同时验证本地状态和服务端拒绝矩阵
OWASP MASVS-RESILIENCE 将抗逆向与抗篡改作为纵深防御控制。对应 token 解析,证据包应包含候选摘要、保护规则、被保护方法签名、依赖版本、测试 claims、异常输入、缓存清理、本地按钮状态和服务端 401、403 用例。控制目录本身不能证明候选已达到具体强度。
门禁要主动篡改测试样例中的 exp、iss、aud、scope、sub 和本地角色字段,确认客户端不会因为可解码就显示持续高权限状态;同时服务器必须对错误签名、错误 audience、撤销 token、重复 proof 和越权对象分别拒绝。静态构建通过、脚本退出零或界面隐藏按钮都不能替代这些回执。
如果业务还需要离线许可,可查看[离线许可逻辑的 VMP 范围](/zh-cn/articles/offline-license-logic-vmp-scope/),不要把 OAuth access token 当长期离线授权。需要提交候选与令牌流程,可从[御盾中央平台申请加固服务](https://www.leonadev.com/console/)。登录、注册、价格、购买和控制台统一由中央平台承接,提交时不得包含真实 token 或生产密钥。
- decode、verify、protocol validation 与 authorization 类型分离
- 客户端 scope 和角色仅控制体验,不决定资源访问
- 资源服务器独立验证 iss、aud、exp、token 类型和允许算法
- 原生授权使用外部 user-agent 与 PKCE,不依赖 APK 内 secret
- DPoP 或 Keystore 调用不复制原始私钥到保护代码
- 401、403、刷新失败与撤销响应都会清理相应本地状态
- 每个受保护资源都有服务器对象级授权拒绝用例
- 真实候选、设备、服务端版本与回执通过摘要绑定
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| JWT access token 可携带 issuer、audience、expiry、scope 与 client 标识供资源服务器验证。 | RFC 9068 JWT access token profile 描述资源服务器可验证的 JWT access token 声明。 | JWT 格式不保证 token 足够短命,也不保证签发与授权策略正确。 |
| OAuth 部署需要限制重定向、令牌重放、授权码注入和不安全 grant。 | RFC 9700 OAuth Security BCP 汇总 OAuth 当前安全建议与风险。 | 安全 BCP 不决定具体业务 API 的最终 scope、对象权限或商业策略。 |
| DPoP 可把 access token 绑定到客户端持有的密钥并降低被盗令牌重放风险。 | RFC 9449 OAuth DPoP 说明 proof、密钥绑定和请求上下文验证。 | DPoP 不能证明设备可信,也不替代短有效期、撤销和服务端授权。 |
| 原生 App 是公共客户端,应使用外部 user-agent 与 PKCE,而非依赖客户端 secret。 | RFC 8252 OAuth 2.0 for Native Apps 规定原生应用的授权流程安全实践。 | 该规范不定义业务 API 的最终 scope、授权时长或对象级权限。 |
| Android Keystore 可以限制密钥用途,并在设备支持时使用硬件保护。 | Android Keystore 说明密钥存储、用途限制、用户认证和硬件条件。 | Keystore 不保护送入业务函数后的明文,也不能证明设备未被控制。 |
| 移动端抗逆向与抗篡改属于纵深防御,不能替代服务端授权和发布链。 | OWASP MASVS-RESILIENCE 定位移动端 resilience 控制及其安全目标。 | 控制目录不证明某个候选已达到防护强度,也不提供攻击阻断结论。 |
| 声明白名单映射、密钥证明编排和关键失败清理是优先 VMP 候选。 | 工程判断:这些小方法直接影响本地状态与密钥使用路径,并可用固定输入复核。 | 是否纳入仍要基于实际调用图、性能、异常和设备兼容证据。 |
| JOSE、JSON、HTTP 和浏览器栈通常不应因位于同一链路而整体虚拟化。 | 工程判断:扩大平台耦合范围会增加回归面,却不改变服务器授权责任。 | 项目若存在具体旁路证据,仍需按方法单独评审,不能机械排除。 |
| 服务端必须独立执行对象级授权、撤销与重放判断。 | 项目证据尚未接入:这是发布前必须验证的服务器门禁,不表示当前系统已经实现。 | 本地签名验证、exp 预检和界面隐藏都不能代替真实服务器回执。 |
| 本文不提供性能、客户、排名、收录或攻击阻断结论。 | 项目证据尚未接入:缺少同一候选、设备矩阵、服务端拒绝回执和公开运营数据。 | 文章只给出可复核边界与门禁,不能作为具体产品效果证明。 |
工程常见问题
客户端能解码 JWT 是否说明 token 有效?
不说明。解码只得到字段,签名、issuer、audience、时间、token 类型、撤销和业务授权仍需独立验证。
客户端 scope 包含 admin 是否可以直接显示管理数据?
可以用于入口提示,不能放行数据。资源服务器必须验证 token,并结合主体、租户、对象和当前状态授权。
把 token 解析器全部做 VMP 是否更安全?
通常不是。优先保护有限映射、密钥调用和失败分支,成熟 JOSE 与网络库保持可测试,避免扩大兼容风险。
APK 中的 client secret 经过 VMP 后能成为机密吗?
不能据此建立协议信任。原生 App 属于公共客户端,应使用外部 user-agent、PKCE 和服务器端控制。
Android Keystore 能否防止 token 被滥用?
它可保护密钥材料和限制用途,但攻击者仍可能驱动应用调用;服务器还需绑定上下文、限速、撤销和授权。
客户端发现 exp 未过期,是否可以离线执行高权限操作?
不可以。exp 只是时间声明,不能证明未撤销、未重放或拥有对象权限,高权限操作仍应由服务器确认。
示例脚本为什么不直接解析完整 JWT?
为了避免把 payload 解码误解为验签。脚本只分类公开 claims,生产必须使用标准 JOSE 库和服务端授权。
怎样验证 VMP 后授权边界没有被破坏?
同时测试本地声明篡改、缓存清理和服务器错误签名、错误 audience、撤销、重放与对象越权拒绝矩阵。