CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
OAuth 2.0 与 OpenID Connect/课时/PKCE 与 SPA
RawBlame
课时目录12 节

OAuth 2.0 与 OpenID Connect

  1. 01OAuth 是什么
  2. 02授权码流
  3. 03PKCE 与 SPA
  4. 04OpenID Connect
  5. 05Scope 与权限
  6. 06Refresh Token
  7. 07Spring OAuth2 Client
  8. 08Resource Server
  9. 09第三方登录
  10. 10OAuth vs 本站 JWT 登录
  11. 11常见安全问题
  12. 12综合练习

PKCE 与 SPA

code_verifier/challenge;公共客户端。

30 分钟

PKCE 与 SPA

这一课你将能做到

  1. 说清 PKCE 解决的问题:公共客户端(SPA、原生 App)没有 client_secret,如何保证被截获的 authorization code 换不走 token
  2. 亲手生成 code_verifier 与 code_challenge(S256),写出 authorize 与 token 两步各自的完整参数
  3. 为 Next.js / 移动端定架构:授权码 + PKCE,verifier 用完即焚,secret 永不进前端

为什么要学

上一课的授权码流有一个隐藏前提:换 token 时能出示 client_secret,授权服务器凭它确认「来换票的确实是我发过 code 的那个应用」。

但 SPA 和原生 App 是「透明盒子」:

  • SPA 的代码直接下发给浏览器,任何人 F12 或抓包就能看到全部源码;
  • 移动 App 的安装包可以被反编译,secret 藏在哪个字符串常量里都能被搜出来。

所以规范把这类应用定义为 公共客户端(public client):根本不发 secret。可没有 secret,攻击者只要在 redirect 环节截到 code(恶意 App 抢注册同 scheme、网络中间人、日志泄露),就能抢先去换 token——这就是 code 拦截攻击。

PKCE(Proof Key for Code Exchange,读作「pixy」)的解法优雅得像暗号对答:

  • 发起授权时:客户端生成随机暗号原文(verifier),把它的 SHA-256 摘要(challenge)交给授权服务器;
  • 换 token 时:客户端交出暗号原文(verifier);
  • 授权服务器:SHA256(verifier) 等于当初存的 challenge 吗?相等才发 token。

暗号原文从头到尾没离开过你的客户端。攻击者就算截了 code,交不出原文,换票必然失败。

OAuth 2.1 草案已把 PKCE 列为所有客户端的推荐项(连有机密后端的也一样)。面试高频:「PKCE 解决什么问题」「为什么 S256 优于 plain」「PKCE 和 state 的区别」——本课给到背诵级。

先建立图像

SPA / 移动 App(无 secret)
  │ ① 生成随机 code_verifier(43~128 字符,加密安全随机)
  │ ② code_challenge = BASE64URL( SHA256(verifier) )
  │
  │ ③ GET /authorize?response_type=code
  │        &client_id=spa-client
  │        &redirect_uri=https://app.example.com/callback
  │        &scope=openid profile
  │        &state=随机串
  │        &code_challenge=②的结果
  │        &code_challenge_method=S256
  ▼
用户登录并同意 → 302 回 callback?code=xxx&state=yyy
  │
  │ ④ POST /token:code + code_verifier(原文!)+ client_id
  ▼
授权服务器:SHA256(verifier) == 当初的 challenge ?
  ├─ 是 → 发 access_token
  └─ 否 → invalid_grant(只偷到 code 的人交不出 verifier)
名词人话
code_verifier本次登录的一次性暗号原文,只留在发起方
code_challenge暗号的 SHA-256 摘要,先寄存到授权服务器
S256challenge 的计算方式:SHA-256 再 Base64URL
plain明文把 verifier 当 challenge,仅调试用,禁止生产

比喻:challenge 是你寄存在前台的「保险箱指纹」,verifier 是你手上的指纹本体。小偷只偷到存包条(code),没有指纹本体,前台不认。

跟着做

步骤 1:Node 生成一对 verifier/challenge

// 中文注释:服务端用 node:crypto;浏览器版见步骤 2
import crypto from "node:crypto";

function base64url(buf) {
  return Buffer.from(buf)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

const verifier = base64url(crypto.randomBytes(32)); // 编码后 43 字符,满足规范
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());

console.log("verifier  =", verifier);
console.log("challenge =", challenge);

验证:把 verifier 再哈希一次,结果必须与 challenge 完全一致——这就是授权服务器将要做的事。

步骤 2:浏览器版(Web Crypto API)

// 中文注释:SPA 运行时生成;verifier 存内存或 sessionStorage,用完即删
function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

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

步骤 3:带 PKCE 的 authorize 请求

GET https://auth.example.com/authorize
  ?response_type=code
  &client_id=spa-client
  &redirect_uri=https://app.example.com/callback
  &scope=openid profile
  &state=e5f1a9c2(防 CSRF,与 PKCE 各司其职)
  &code_challenge=U4dM3q...(步骤 1 的 challenge)
  &code_challenge_method=S256

verifier 此刻存进当前会话的 sessionStorage(或 BFF 的服务端内存),不要放进长期 localStorage。

步骤 4:带 verifier 的换 token 请求

# 中文注释:公共客户端没有 client_secret,靠 verifier 自证身份
curl -i -X POST https://auth.example.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=callback收到的一次性code" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "client_id=spa-client" \
  -d "code_verifier=步骤1的verifier原文"

步骤 5:思维实验——交错暗号会怎样

  1. authorize 时提交 challenge A(由 verifier A 算出)
  2. 换 token 时故意提交 verifier B
  3. 预期:invalid_grant,没有 access_token

把结论写进笔记:PKCE 校验失败 ≠ 账号密码错误,是「客户端证明失败」,排查方向是 verifier 是否在跳转过程中丢失或对不上号。

步骤 6:Next.js 项目的落点

方案 A(推荐,BFF):Next Route Handler / Server Action 充当机密端
  → verifier 存服务端(cookie 会话或内存)
  → 换 token 在服务端完成
  → 前端只拿最终结果(本站 JWT / 会话 cookie)

方案 B(纯前端 SPA):
  → verifier 存 sessionStorage,换完立刻删除
  → 清醒认识到 XSS 仍是风险:PKCE 挡 code 拦截,不挡 XSS

禁止:把任何 client_secret 写进 NEXT_PUBLIC_* 或打进前端包

讲清楚要点

  1. 公共客户端没有 secret,所以必须用 PKCE——verifier 就是「动态的、一次性的 secret」,且从不在网络上先以原文传出。
  2. S256 优于 plain:plain 模式下 challenge 就是 verifier 原文,若 authorize URL 被日志/Referer 记录,攻击者拿到 challenge 即拿到 verifier,防线归零。S256 只暴露单向哈希。
  3. PKCE 与 state 一起开:state 防「别人的 code 被塞进我的会话」(CSRF);PKCE 防「我的 code 被别人换走」(拦截)。攻击面不同,缺一不可。
  4. verifier 一次一对、用完即焚:成功或失败后都清除;复用 verifier 等于把一次性暗号用成固定密码。
  5. 有后端的机密客户端也可以加 PKCE:OAuth 2.1 草案建议所有客户端都用;框架默认开启时无需关闭。
  6. PKCE 不是 XSS 银弹:XSS 可以在登录瞬间读内存里的 verifier。所以 BFF 架构 + HttpOnly cookie + CSP 仍是 SPA 的完整答案。

你可能会懵的地方

  • PKCE 能取代 state 吗? 不能。state 防 CSRF(回调是谁发起的);PKCE 防拦截(换票的是不是发起方)。两个问题两个参数,同时启用。

  • 移动 App 用自定义 URL Scheme 回调安全吗? 有风险:恶意 App 可注册相同 scheme 抢回调。缓解:iOS 用 Universal Links / Android 用 App Links(域名验证),并始终配合 PKCE——即使 scheme 被抢,抢到 code 也换不走 token。

  • Keycloak / Spring Authorization Server 里怎么开 PKCE? 客户端注册时把认证方式设为 none(public client),并要求 code_challenge_method=S256;Keycloak 在客户端设置的「Advanced」里把 PKCE Method 从留空改为 S256。

  • verifier 存数据库长期复用行吗? 不行。一次授权一对是协议语义也是安全要求:重放保护依赖「一次性」。

  • 为什么 verifier 要求 43~128 字符? RFC 7636 要求 32 字节熵下限,43 字符 base64url 恰好是 32 字节的编码结果。太短的 verifier 会被暴力枚举。

错法对照

错法问题正解
SPA 继续用 Implicit「反正没 secret」token 暴露在 URL,无 refresh,已废弃授权码 + PKCE
authorize 带了 challenge,token 请求忘了 verifier授权服务器校验失败,invalid_grant两步参数成对出现,别漏一步
verifier 用 userId 等可预测值攻击者可猜中,PKCE 失效加密安全随机 ≥32 字节
code_challenge_method 用 plain 上生产URL 泄露即 verifier 泄露生产一律 S256
把 client_secret 塞进 NEXT_PUBLIC_任何人可提取,机密性归零secret 只进服务端;前端走 PKCE
以为开了 PKCE 就能放宽 redirect_uri开放重定向仍会漏 coderedirect 白名单照旧严格

卡点急救

  1. 现象:换 token 报 invalid_grant 且提示 PKCE 相关。核对三件事:token 请求带的是 code_verifier(不是 challenge);verifier 是否就是当初算 challenge 的那一份(排查刷新页面导致内存丢失);编码是否一致(Base64URL 不能带 =、+、/)。
  2. 现象:授权服务器提示 code_challenge_method 不支持或 transform 错误。服务端可能只认 S256;确认你传的是 S256 而非 plain,且 challenge 是「先 SHA-256 再 Base64URL」,不是先 Base64URL 再哈希。
  3. 现象:SPA 刷新页面后再点登录一直失败。verifier 丢在刷新前的内存/sessionStorage 里,与新生成的 challenge 对不上。规则:每次点击登录都重新生成一对,发起跳转前写入,回调后取出并立刻删除。

面试常问

  • PKCE 解决什么问题?流程是怎样的? 解决公共客户端(无 client_secret)在授权码流中的 code 拦截问题。流程:客户端生成随机 code_verifier,把 SHA256(verifier) 作为 code_challenge 随 authorize 请求提交;换 token 时提交 verifier 原文;授权服务器比对哈希一致才发 token。一句话:把「证明我是发起方」从静态 secret 换成一次性动态证明。

  • 为什么用 S256 而不用 plain? plain 把 verifier 原文当 challenge,authorize 请求若被日志/代理/Referer 记录,攻击者拿到 challenge 等于拿到 verifier,防线归零;S256 只暴露单向哈希,从 challenge 反推不出 verifier。

  • PKCE 和 state 都要吗?它们像吗? 都要。state 绑定会话防 CSRF(防「把别人的 code 塞进我的回调」);PKCE 绑定授权请求防拦截(防「我的 code 被别人换走」)。二者都是一次性随机值,但校验方不同:state 由客户端校验,verifier 由授权服务器校验。

自测三问

  1. 写出 verifier 与 challenge 的生成关系,并指出谁先发给授权服务器、谁在换 token 时提交。
  2. 「只截到 code 的攻击者」为什么换不走 token?
  3. Next.js 项目里 client_secret 可以出现在哪、绝对不能出现在哪?

小结一句话

PKCE = 给无 secret 的公共客户端配一次性动态口令:先交 challenge、后交 verifier,哈希对上才发 token;SPA/移动端的默认姿势。

延伸

  • RFC 7636:Proof Key for Code Exchange
  • oauth.net:PKCE
  • 下一课:OpenID Connect——OAuth 只管授权,那「第三方登录」靠什么证明「这人是谁」?
加载本节练习…
← 上一课授权码流下一课 →OpenID Connect