PKCE 出问题的典型样子是这样的:授权码流程在网页端正常,移动端 SDK 升级后,新设备每次登录都以 invalid_grant 结束,服务端日志显示 /token 请求没带 code_verifier,或者带的值哈希后对不上之前发出的 challenge。有缓存会话的设备照常可用,测试时很容易漏掉;排查往往从「哪个值要做哈希、哪个值发到哪里」这个问题开始。

PKCE 全称 Proof Key for Code Exchange,是 OAuth 的扩展,让那些身上没有 server secret 的客户端也能证明授权请求是自己发起的。RFC 7636 在 2015 年为移动和原生应用引入。RFC 9700(2025 年)要求公开客户端必须用 PKCE、建议机密客户端也用;OAuth 2.1 草案 要求所有客户端都用,唯一例外是授权服务器确信其正确实现了 OpenID Connect nonce 的机密客户端。机制小得能一段话讲清,又容易写错——错得能通过 happy-path 测试,等真实客户端第一次重试就崩盘。ZeroTool 的 PKCE 生成器 在你的浏览器里就把这对值算好,同时给出 /authorize URL 与 /token 交换的 curl 命令;遇到不符合 RFC 字符集的 verifier 会拒绝计算 challenge,日志里的 challenge 对不上时还能告诉你原因。

PKCE 到底干了什么

标准 OAuth 2.0 授权码流程长这样:用户在你的客户端里点”用 Acme 登录”,你把他重定向到 https://acme.example/authorize,附上 client_id 与 redirect_uri。用户登录后,Acme 把他带回你的 redirect_uri,URL 上挂着一个不透明的 code。客户端后端拿这个 code 去 https://acme.example/token 换 access token,认证方式是你和 Acme 之间共享的 client_secret。

软肋在重定向那一步。移动平台上,任何人都能注册一个 myapp://callback 的处理器;SPA 里这个 code 短暂地出现在 window.location,任何能读 URL 的扩展或脚本都看得见。如果攻击者在你客户端用 code 之前截到了它,又知道你的 client_id,他完全可以替你完成 token 交换。

PKCE 把这个漏洞堵上了。客户端在跳转到 /authorize 之前,先生成一个随机 secret 叫 code_verifier,对它做 SHA-256,再 base64url 编码得到 code_challenge。/authorize 请求带的是 challenge——不是 verifier。服务端把 challenge 跟签发的 code 绑在一起。等客户端拿着 code 来 /token 时,把原始 code_verifier 也送上去。服务端再哈希一次,确认匹配。截到 code 的攻击者从来没看过 verifier,根本伪造不出第二段请求。

verifier 是 secret,challenge 是公开承诺。两者配起来,让”截 code”攻击失效——因为完成交换的第二步需要一份从未在网络上出现过的知识。

五种现在就需要 PKCE 的场景

客户端类型为什么需要 PKCE/token 端点认证方式
原生移动 App(iOS / Android)没法安全保存 client_secret,RFC 7636 的原始场景仅 code_verifier——public client
SPA(React / Vue / SvelteKit 已 hydrate)浏览器里同样的问题,JS 里任何东西都对扩展和 devtools 透明仅 code_verifier——public client
带本地 callback server 的 CLI 工具http://127.0.0.1:PORT 这种 loopback 重定向,本地任何进程都能劫持仅 code_verifier——public client
桌面应用(Electron / 原生)URL handler 可以系统级注册仅 code_verifier
Confidential Web App(服务端渲染)RFC 9700 §2.1.1 建议使用;OAuth 2.1 草案 §7.5.1.1 要求使用,除非服务器有合理把握该客户端正确实现了 OpenID Connect noncecode_verifier 加 client_secret

最后一行是很多团队往 OAuth 2.1 迁移时最容易踩的坑。哪怕你那个手握 client_secret 的 Express 后端,也应该带 PKCE:secret 只证明客户端身份,挡不住攻击者把偷来的授权码注入到这个客户端的会话里,PKCE 能挡。所以 RFC 9700 建议机密客户端也用 PKCE;OAuth 2.1 草案则规定必须用,只有 OpenID Connect nonce 那一种例外,而且例外情况下仍然建议用。

走一遍工坊

打开 PKCE 生成器,脚本一跑,页面就把 verifier 渲出来了。默认 43 个字符:用 crypto.getRandomValues 取 32 字节随机数再做 base64url,正是 RFC 7636 §4.1 与 §7.1 给出的做法(256 位熵)。服务商对长度有要求时,把 长度 改成 43 到 128 之间的任意值;verifier 上方那一行会显示长度和熵。点 重新生成(或 Ctrl/⌘+Enter)立刻换一组,challenge 随即重算。把自己应用里的 verifier 粘进来,工具会按 RFC 语法逐字检查,并指出第一个出错的字符。

重新生成 旁边是方法选择:S256 与 plain,默认 S256。RFC 7636 §4.2 规定能用 S256 的客户端必须用 S256,plain 只留给算不了 SHA-256 的客户端;OAuth 2.1 草案 §7.5.2 则禁止 plain。但并非所有服务器都这样执行:飞书的 获取授权码 接口接受 plain,不带 code_challenge_method 时也按 plain 处理。选 S256,并且每次都把方法写明。

code_challenge 卡片显示的就是真正发到 /authorize 的值,从这里复制。每换一个 verifier 它都会变——新 verifier 的哈希自然是新的。

下面三个面板各管一件事。核对 code_challenge:粘贴应用实际发出的值,工具判断它是否与 verifier 配对,或者属于哪种常见错误——末尾多了 =、标准 Base64、十六进制摘要、直接用了 verifier、对解码后的随机字节做了哈希。授权请求 URL:填入授权端点、client_id、redirect_uri、scope,生成完整的 /authorize URL,含 response_type=code、当前 challenge、code_challenge_method、随机 state,scope 含 openid 时还有随机 nonce。把这串贴到浏览器地址栏就能走流程第一段,登录后从重定向 URL 里取 code。

token 请求(cURL) 生成对应的 /token 请求。把重定向里拿到的 code 填进它的 code 输入框,再执行命令。注意 code_verifier body 参数里放的是 verifier 原文,不是 challenge。verifier 和 challenge 对得上、code 没过期,provider 就会返回 token。对不上就是 invalid_grant——和生产环境里 PKCE 接错时一模一样的报错,先用 核对 code_challenge 查。

密码学就那几行

四行真正起作用的代码,可以念出来听。

// 1. 生成随机 verifier
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const verifier = base64url(bytes);   // 32 字节 → 43 字符

// 2. 哈希为 challenge
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = base64url(new Uint8Array(hash));   // 32 字节 → 43 字符

function base64url(bytes) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}

这样产出的 verifier 长度永远是 43——32 字节按无 padding 的 base64 编码刚好 43 字符。RFC 7636 允许 43~128 字符,所以 43 是合法下限。它也是本工具的默认值:RFC 7636 §7.1 要求至少 256 位熵,32 字节随机数正好满足。

哈希用的是 SubtleCrypto,Web Crypto API 的一部分,只要页面走 HTTPS,现代浏览器都能用。如果检测不到 crypto.subtle(一般是有人用 http:// 打开了页面),工具会拒绝运行,状态行直接提示换 HTTPS。

Base64url 是 base64 的小改版:+ 换 -,/ 换 _,结尾的 = padding 全剥掉。RFC 7636 指定的就是这个格式。这里是常见的静默 bug——标准 btoa() 的输出含 +、/,结尾还有 =,永远不会等于服务器用 verifier 算出的 base64url 哈希,换 token 时只得到一句笼统的报错。把这个值贴进 核对 code_challenge,工具会指出是哪种编码错误。

五个会让登录静默失败的错

1. 把 challenge 当 verifier 送到 /token。 /authorize 收 challenge,/token 收 verifier。它俩都是 43 字符的 base64url 字符串,调错了肉眼看不出来。口诀:challenge 是你公开承诺的那串,verifier 是你私下持有要证明的那串。

2. code_challenge_method 前后不一致。 /authorize 上声明 S256,但客户端忘了在本地做 SHA-256,把原始 verifier 当 challenge 送过去?服务端会对你 /token 时送的 verifier 算哈希,再跟你”刚才那个原始 verifier”比对,自然对不上。漏传 method 是反过来的同一个错:RFC 7636 §4.3 规定不带 code_challenge_method 即为 plain,正确的 S256 challenge 会被拿去和 verifier 原文比较。method 要和 challenge 一起发,全局默认 S256。

3. 跨次重试复用 verifier。 每一次授权流程都该有自己的 verifier/challenge 对。如果用户取消了授权同意页、你重发 /authorize,要起一个新的 verifier——服务端有可能把你上一次发起、自己已经放弃的 verifier 记在那里。

4. verifier 存错地方。 SPA 用 sessionStorage,像下面浏览器示例那样,换完 token 就删掉。verifier 只需要在同一个标签页里撑过一次重定向。sessionStorage 属于当前标签页,关闭标签页即清除;localStorage 在登录结束后仍然留着,而且同源的所有标签页共用,两个标签页同时登录会互相覆盖 verifier。两者都挡不住脚本注入:同源下运行的任何 JavaScript 都能读到。原生 App 走平台安全存储(iOS Keychain、Android 用 Android Keystore 中的密钥加密),别明文落盘。

5. 嫌 S256 麻烦改用 plain。 上面那五行就是 S256 的完整实现,OAuth 库也都内置了。规范没给 plain 留余地:RFC 7636 §4.2 只允许算不了 SHA-256 的客户端用它;RFC 9700 §2.1.1 指出 S256 是目前唯一不会在授权请求里暴露 verifier 的方法;OAuth 2.1 草案 §7.5.2 直接禁止。服务器的做法各不相同:LINE 登录 只接受 S256,Keycloak 仍可把 plain 设为客户端要求的方法,飞书在缺省 method 时按 plain 处理。

接主流 provider 怎么接

code_challenge 的参数和请求形态在所有 provider 之间是一样的——RFC 7636 标准化了。差异只在注册环节:要不要勾”公共客户端”或”允许 PKCE”、能接受哪些 redirect_uri scheme。

Auth0 的 文档 把带 PKCE 的授权码流程用于无法保存 client secret 的应用,例如原生 App 与单页应用,它的 SDK 会替你加上参数。端点是 https://YOUR_TENANT.auth0.com/authorize 与 https://YOUR_TENANT.auth0.com/oauth/token。

Okta 的 Authorization Code with PKCE 指南 第一步是创建 Native Application 或 Single-Page Application 集成,也就是公开客户端的应用类型。用 org 授权服务器时,端点是 https://YOUR_OKTA_DOMAIN/oauth2/v1/authorize 与 https://YOUR_OKTA_DOMAIN/oauth2/v1/token。

Keycloak 在每个客户端的 PKCE method 选项里设置 PKCE(Server Administration Guide):留空表示客户端发了参数才校验,选 S256 或 plain 则强制要求该方法。选 S256。端点跟 realm 走:https://YOUR_KEYCLOAK/realms/YOUR_REALM/protocol/openid-connect/{authorize,token}。

Amazon Cognito 在授权码模式中 支持 PKCE。challenge 发到授权端点 https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/authorize,verifier 发到 /oauth2/token。

Spotify 把 Authorization Code with PKCE 列为移动 App、单页应用以及其他无法安全保存 client secret 的应用的推荐流程。参数和别家一样。

三段代码上手

Python,使用 requests:

import base64, hashlib, secrets, requests

verifier = secrets.token_urlsafe(64)[:64]
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()

# 1. 把用户跳到 /authorize,带上 challenge
# 2. 在 redirect_uri 上收到 code
# 3. 用 verifier 换 token:
response = requests.post(
    "https://acme.example/token",
    data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": "https://yourapp.example/callback",
        "client_id": "YOUR_CLIENT_ID",
        "code_verifier": verifier,
    },
)

JavaScript / TypeScript(浏览器):

const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(hash));

sessionStorage.setItem('pkce_verifier', verifier);

const authUrl = `https://acme.example/authorize?` + new URLSearchParams({
  response_type: 'code',
  client_id: 'YOUR_CLIENT_ID',
  redirect_uri: 'https://yourapp.example/callback',
  code_challenge: challenge,
  code_challenge_method: 'S256',
  state: crypto.randomUUID(),
});
location.href = authUrl;

function base64url(bytes: Uint8Array) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Bash,拿到 code 之后跑 /token:

curl -X POST https://acme.example/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTHORIZATION_CODE" \
  -d "redirect_uri=https://yourapp.example/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code_verifier=$CODE_VERIFIER"

浏览器端生成器为什么值得做

公网上的 PKCE 生成器多数都把密码学放在 JS 里——那部分本来就简单。差别在于工具周围还配套了什么。

tonyxu-io.github.io/pkce-generator 是大家都收藏的那一个:UI 极简,RFC 7636 示例输出正确,只有英文。2026-10-01 实测它不校验输入:42 个字符、带 + / = 的 verifier、空输入都会算出 challenge。Ping Identity 的 PKCE Code Generator 在本地生成,但 verifier 框是只读的,没法核对你自己应用里的 verifier。oauth.com/playground 互动性强但会拉自己的测试 issuer 跑完整 round trip,学概念时好用、调你自己的 provider 就重了。

ZeroTool 这只工具补的缺口是:把生成器和可粘贴的 /token curl 直接绑在一起,让你能把交换重放到你真正的 provider 上;按 RFC 语法检查粘贴的 verifier;日志里的 challenge 对不上时说明原因;同一个界面四语言呈现。verifier 每次页面加载都会重新生成,不写进 localStorage、sessionStorage 或网址——持久化策略标的就是 disabled,页面也不加载统计与广告脚本。两次调试之间刷新 tab,上一次的 verifier 就消失了,和真实 OAuth helper 该有的”无状态”行为对得上。

延伸阅读