为什么 OAuth 的 client_id 不能当秘密:一次 Device OAuth 安全加固实践
前言
大家好,今天想分享一个我们在做 OAuth Device Flow 时遇到的真实问题。
Device Flow 很适合 CLI、桌面端、电视、IoT 这类不方便输入密码的场景。用户在设备上看到一个链接或验证码,打开浏览器完成授权,设备端再轮询 token。
但我们很快遇到一个安全困扰:
client_id 是公开的。
别人看到以后,完全可以说:
我不申请自己的 client_id 了,直接拿你的用。
这听起来像是 client_id 泄露问题,但本质上不是。OAuth 里的 client_id 本来就不是 secret。它只是应用标识,不是应用身份证明。
问题在哪里
原来的 Device Flow 大概是:
客户端 -> /oauth2/device_authorization
带 client_id,拿 device_code / user_code
客户端 -> /oauth2/token
带 client_id + device_code 轮询 token
服务端能校验:
client_id 是否注册
scope 是否允许
device_code 是否属于 client_id
轮询 IP 是否一致
这些都有价值,但挡不住一个问题:
别人拿到 client_id
自己发起 device flow
用户完成授权
别人也能拿 token
因为服务端只知道“这是某个 client_id 的请求”,不知道“这是不是官方客户端的某个真实安装实例”。
不要把 client_secret 塞进客户端
一个直觉方案是:给客户端加 client_secret。
但这在 CLI、桌面端、移动端里基本是假安全。只要 secret 跟客户端一起发出去,它迟早能被提取。混淆、加壳、硬编码都只是增加一点逆向成本,不是强认证。
所以我们换了个思路:
不要试图隐藏 client_id
而是让仅有 client_id 不够用
client instance 的想法
我们引入了 client_instance_id。
它表示某一次安装、某台机器、某个本地运行实例。
流程是:
1. 客户端首次启动生成一对本地密钥
2. 私钥留在本机
3. 公钥上传给服务端
4. 服务端返回 client_instance_id
5. 后续 OAuth 请求都带 client_instance_id
注册接口类似:
POST /oauth2/client/instances/register
请求里带:
{
"client_id": "xxx",
"public_jwk": {
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
},
"platform": "darwin-arm64",
"version": "1.2.3"
}
服务端记录:
client_instance_id
client_id
public_jwk
jkt
platform
version
status
这里的 jkt 是 JWK thumbprint,也就是公钥指纹。
DPoP 是什么
仅有 client_instance_id 还不够,因为别人也可以伪造这个参数。
所以我们配合 DPoP。
DPoP 全称是 Demonstrating Proof of Possession。它解决的是:
请求方不只是知道一个 ID
它还必须证明自己持有某把私钥
客户端每次请求时都会加一个 header:
DPoP: <signed-jwt>
这个 JWT 由客户端本地私钥签名。里面包含:
{
"htu": "https://auth.example.com/oauth2/token",
"htm": "POST",
"iat": 1710000000,
"jti": "random-id"
}
服务端可以校验:
签名是否有效
htu 是否是当前 URL
htm 是否是当前 method
iat 是否在时间窗口内
jti 是否重放
proof 里的公钥 thumbprint 是否等于注册实例的 jkt
这样 client_instance_id 回答:
你声称自己是哪个实例?
DPoP 回答:
你真的持有这个实例登记过的私钥吗?
DPoP 解决什么,不解决什么
DPoP 很有价值,但不要误解它。
它能解决:
偷到 device_code 也不一定能 poll token
偷到 access token 也不一定能调用 API
偷到 refresh token 也不一定能刷新
前提是服务端真的做了绑定和校验。
但它不能单独解决:
别人拿你的 client_id 自己生成一把 key
然后完整发起一次新的 device flow
所以 DPoP 不是“官方客户端证明”。它证明的是“私钥持有”。
如果要证明这是官方客户端,还需要叠加:
实例注册准入
版本白名单
发布签名
平台 attestation
scope 分级
限流和风控
这次实践的结论
这次实践给我的最大感受是:
client_id 不是 secret
不要把公开标识当认证
更合理的做法是把风险拆开:
client_id 标识应用
client_instance_id 标识安装实例
DPoP 证明私钥持有
scope 和策略控制权限
灰度开关控制上线风险
最终目标不是让 client_id 变得不可见,而是让“只拿到 client_id”不再足够。
这就是我们这次 Device OAuth 加固的实践。后续真正打开服务端校验时,关键会是三件事:
device_code 绑定实例
refresh_token 绑定实例
API token 绑定 DPoP proof
到了那一步,Device Flow 才会从“谁知道 client_id 谁能发起”,逐步变成“只有被登记、能证明私钥持有的实例,才能稳定完成授权链路”。

浙公网安备 33010602011771号