#React#DevOps

OAuth 2.0 PKCE 与 Token 旋转

SPA 和移动端用隐式流不安全,PKCE 是现代标配。本文讲清 PKCE 原理(code_verifier/code_challenge)、为什么替代 implicit flow、Access Token vs Refresh Token 的生命周期、Token 旋转与撤销,给一套生产级 OAuth 客户端实现。

本文要点

  • 主题聚焦:React、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月18日·更新于 2026年7月23日

我审过不少团队的 OAuth 实现,发现一个普遍现象:大家都能把"用 GitHub 登录"跑通,配置抄一份 .env,NextAuth 一接,登录按钮能点,token 能拿到,就算完事。但一旦你问他"你的 PKCE 用的是 S256 还是 plain""你的 refresh token 有没有做 rotation""access token 放哪了"——绝大多数人开始含糊。

这不是开发者的错,是 OAuth 这套东西的历史包袱太重。2012 年的 RFC 6749 把 SPA 和移动端塞进了"隐式流程"和"密码流程"两个其实根本不安全的口子,后面花了将近十年才用 PKCE 和 OAuth 2.1 把这些窟窿补上。本文就把这块讲透:PKCE 到底是怎么挡住授权码截获的、隐式流程为什么必须死、两个 token 的生命周期怎么设计、refresh token 旋转怎么落地、撤销黑名单怎么做,最后给一套 NextAuth/Auth.js 的生产级 PKCE 配置。

读完你会有一套能直接抄进生产的判断标准,而不是再抄一遍 .env。

一、PKCE 要解决的问题:授权码被截获

先回到授权码流程(Authorization Code Flow)本身。它是两步换 token:

text
步骤 1  浏览器 → 授权服务器:跳转,用户登录+同意
        授权服务器 → 浏览器:回跳 redirect_uri,带 authorization code
步骤 2  客户端后端 → 授权服务器:code + client_secret → 换 access_token

为什么绕这一步不直接给 token?因为回跳发生在浏览器这个不可信通道,URL 里的东西会被浏览器历史、Referer 头、反向代理日志、浏览器扩展全部捕获。code 是个一次性短码(寿命几十秒),即便被截获,也只能换一次,而且必须配 client_secret 才能换。client_secret 只存在服务端,前端永远拿不到。所以授权码流程的前提是:你有一个后端能安全保管 client_secret。

问题来了:纯前端 SPA 和移动 App 没有后端,或者即便有后端,客户端本身也是"公开"的。你编译进 App 里的任何"密钥",反编译一下就出来了;你打进前端 bundle 的任何 secret,打开 DevTools 就看到了。这类客户端叫公开客户端(public client),它们无法安全持有静态秘密。

没有 client_secret 保护,授权码流程的"两步换 token"就漏了一半:如果攻击者能截获那个回跳的 code(在 Android 上拦截自定义 scheme 的 Intent、在 SPA 里通过重定向到一个恶意页面、读浏览器历史),他就能抢在你前面把 code 拿去换 token。这就是授权码截获攻击(Authorization Code Interception Attack),OAuth 安全 RFC 6819 里有专门描述。

PKCE(Proof Key for Code Exchange,RFC 7636)就是为这个场景设计的。它最早是给移动 App 用的,后来被证明对 SPA 同样有效,现在已经是所有公开客户端的强制要求。

二、PKCE 原理:code_verifier 与 code_challenge 的动态握手

PKCE 的核心思路一句话:用一个一次性的动态秘密,替代静态的 client_secret。客户端每次发起授权都临时生成一个秘密,只存在客户端内存里,授权服务器拿不到原值,只能拿到它的哈希。换 token 时客户端亮出原值,服务器一哈希比对,匹配了才发 token。

这个动态秘密叫 code_verifier,它的哈希叫 code_challenge。完整流程:

text
客户端(发起授权前)
  1. 生成 code_verifier:43~128 位的高熵随机字符串
  2. 算 code_challenge = BASE64URL( SHA256( code_verifier ) )   ← method=S256
  3. 跳转授权服务器,带上 code_challenge + code_challenge_method=S256

授权服务器
  4. 记下:这个授权会话绑定了这个 code_challenge

用户登录+同意后,回跳带 code

客户端(换 token 时)
  5. 拿 code + code_verifier(原值) 去换 token

授权服务器
  6. 重新算 BASE64URL(SHA256(code_verifier)),跟步骤 3 记下的 challenge 比对
  7. 匹配 → 发 token;不匹配 → 拒绝

关键在于:code_verifier 只在最后一步、在换 token 的直接请求里才发出去,而且是一次性使用。攻击者如果只截获了回跳时的 code(这是最容易截获的部分),他拿着 code 去换 token 时拿不出 code_verifier,服务器一比对就拒绝。攻击者必须同时截获 code 和 code_verifier 才能得手——而 code_verifier 在内存里,从不经过 URL、浏览器历史、Referer 这些公开通道。

这就是 PKCE 的精髓:把"静态密钥"换成"一次性动态证明",让截获 code 变得无利可图。

S256 vs plain:永远用 S256

code_challenge_method 有两个值:

  • plain:code_challenge = code_verifier(原样)。这只有在客户端算力极弱、无法做 SHA256 的时候才用,等于没加密。RFC 7636 里 plain 的存在纯粹是兼容性历史包袱。
  • S256:code_challenge = BASE64URL(SHA256(code_verifier))。这是工程上的唯一选择。

生产环境永远用 S256。 如果你的 IdP 不支持 S256,换一个 IdP。任何现代授权服务器(Auth0、Keycloak、Authing、Logto、Google、GitHub、Microsoft)都支持。

手写一遍 PKCE 生成,理解到底发生了什么

虽然你不会真的手写(后面会讲用库),但看一遍原始代码,PKCE 就不再是黑盒:

js
// pkce.js —— 手写 PKCE,理解原理
import { randomBytes, createHash } from 'node:crypto';

// 1. 生成 code_verifier:43~128 字符,base64url 编码的高熵随机串
export function generateCodeVerifier() {
  return randomBytes(32).toString('base64url'); // 43 字符,刚好满足最小长度
}

// 2. 由 verifier 算出 challenge(S256)
export function computeCodeChallenge(verifier) {
  return createHash('sha256').update(verifier).digest('base64url');
}

// 3. 发起授权时,把 verifier 存起来(内存 / sessionStorage,不要 localStorage)
//    challenge 放进授权 URL
export function buildAuthorizeUrl(authEndpoint, params, verifier) {
  const challenge = computeCodeChallenge(verifier);
  const url = new URL(authEndpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', params.clientId);
  url.searchParams.set('redirect_uri', params.redirectUri);
  url.searchParams.set('scope', params.scope);
  url.searchParams.set('state', params.state);          // 防 CSRF,必须带
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');
  return url.toString();
}

// 4. 换 token 时,把原 verifier 一起发出去
export async function exchangeCodeForToken(tokenEndpoint, payload) {
  const res = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: payload.code,
      redirect_uri: payload.redirectUri,
      client_id: payload.clientId,
      code_verifier: payload.verifier,  // ← 关键:原值,不是哈希
    }),
  });
  return res.json(); // { access_token, refresh_token?, id_token?, expires_in }
}

注意几个细节:state 参数防 CSRF,跟 PKCE 是两件事,不能互相替代;code_verifier 存在内存或 sessionStorage 里就够(刷新页面就丢,但授权流程本就要求一次走完,不要存 localStorage——那样 XSS 能偷);换 token 用 application/x-www-form-urlencoded,不是 JSON,这是 OAuth 老规矩。

三、为什么隐式流程必须死

理解了 PKCE,就能理解为什么 OAuth 2.0 的隐式流程(Implicit Flow)在 2026 年已经是个历史名词。

隐式流程是 2012 年 RFC 6749 给纯前端 SPA 设计的。当年的背景:SPA 没有后端,又没法存 client_secret,授权码流程跑不了。于是委员会开了个后门——跳过换 token 那一步,授权服务器直接在回跳的 URL fragment(#access_token=...)里把 access token 塞给前端。没有 code,没有 secret,一步到位。

text
隐式流程:
  浏览器 → 授权服务器:response_type=token
  授权服务器 → 浏览器:redirect_uri#access_token=eyJhbG...&expires_in=3600
  前端 JS 从 location.hash 里抠出 token,存起来

听起来很方便,实际是灾难。三个致命问题:

第一,token 直接暴露在 URL fragment 里。 fragment(# 后面的部分)虽然不会发给服务器(不进 access log),但它会进浏览器历史,能被同源的第三方脚本读到,也会被装在浏览器里的恶意扩展截获。授权码流程正是因为这个原因才绕了一步,隐式流程把这一步又省了回去。

第二,没有 refresh token。 隐式流程不返回 refresh token(因为没有安全的地方验证客户端身份,授权服务器不敢发长期凭证)。access token 一过期(通常 1 小时),用户就得重新走一遍登录授权,体验断裂。为了不让人重新登录,很多实现就把隐式流的 access token 寿命拉得很长,这又违背了"access token 必须短期"的原则。

第三,无法证明客户端身份。 整个流程里没有任何机制能确认"来换 token 的就是你这个 App"。任何截获 token 的人都能直接用,你连"是哪个客户端泄露的"都查不到。

这三个问题的根因是同一个:隐式流程本质上是在不安全的通道里传输一个长期有效的凭证,还省掉了所有客户端认证。OAuth 2.0 Security Best Current Practice(RFC 9700,前身是 BCP 212)明确要求不要再使用隐式流程,OAuth 2.1 草案更是把它从规范里彻底删掉。所有现代浏览器的隐私策略(如 Safari 的 ITP)对 fragment 里的 token 也越来越不友好。

正确的替代方案是:授权码流程 + PKCE。SPA 用 PKCE 解决"没有 secret"的问题,用授权码流程的两步换 token 换回安全与 refresh token 的能力。这套组合在 2026 年是公开客户端的唯一推荐姿势,没有例外。移动 App、桌面 App、SPA 全部走它。如果你的项目里还有 response_type=token,这是技术债,排期还掉。

四、Access Token 与 Refresh Token:短期与长期的分工

PKCE 跑通了,你能安全拿到 token。但拿到的是两个寿命完全不同的东西,设计上必须分清。

为什么需要两个 token

如果只有一个 token,你会陷入两难:

  • token 寿命短(15 分钟)→ 安全,但用户每小时要重新登录,体验崩。
  • token 寿命长(30 天)→ 体验好,但一旦泄露,攻击者有 30 天窗口期,你毫无办法。

两个 token 的设计就是为了打破这个两难:

Access TokenRefresh Token
用途调业务 API,代表用户授权只用来换新的 access token
寿命短期(15 分钟 ~ 1 小时)长期(几天 ~ 几十天)
使用频率每次请求都带仅 access 过期时用一次
泄露后果窗口期短,危害有限等于长期钥匙,必须重点保护
撤销难度难(JWT 无状态)或易(查 session)易(服务端可控,因为是"找我要新 token")

核心思想:把"高频使用"和"长期有效"拆开,让高频的那个寿命短,长期有效的那个使用频率低。这样即便 access token 在高频传输中被偷,攻击者也只有十几分钟的窗口;而 refresh token 虽然活得久,但只在偶发的刷新动作里走一次网络,泄露面小得多。

寿命怎么定

经验值:

  • Access Token:15 分钟 ~ 1 小时。我倾向更短(15~30 分钟),尤其是敏感业务。短寿命是你 JWT 撤销难题的最大补救。
  • Refresh Token:7 ~ 30 天。C 端产品可以做到 30 天(配合滑动续期,用户活跃就一直不用重新登录),B 端后台 1 ~ 7 天更稳妥。
  • 绝对过期 vs 滑动过期:滑动过期(每次刷新都顺延)体验好但风险高;绝对过期(不管刷不刷,30 天后必须重新登录)更安全。折中:滑动续期 + 绝对上限(比如最多续到 60 天,之后强制重新认证)。

Refresh Token 必须只在服务端流转

这是铁律:refresh token 永远不应该出现在业务 API 请求里,也不应该被前端 JS 频繁读写。正确做法是:

  • refresh token 存在 httpOnly + Secure + SameSite cookie 里,前端 JS 读不到。
  • access token 可以存在内存(JS 变量)或短期 cookie。
  • access 过期时,前端调一个 /api/auth/refresh 接口,后端从 cookie 里读 refresh token,去 IdP 换新 access token,返回给前端。整个过程中 refresh token 不经过 JS。

这套设计让 XSS 即使偷到了 access token,也只能用十几分钟;偷不到 refresh token(被 httpOnly 保护),就没法续命。很多团队图省事,把 refresh token 也丢 localStorage,这是直接把 30 天的钥匙挂在墙上让 XSS 摘。

五、Token 旋转:Refresh Token Rotation 怎么做

讲到这里,refresh token 仍然有一个残留风险:它寿命长,万一泄露了(比如被中间人截获、被从 cookie 里扒出来、设备丢失),30 天内你毫无办法。

Refresh Token Rotation(刷新令牌旋转) 是针对这个风险的工程措施。思路:每次用 refresh token 换新 access token 时,授权服务器同时签发一个新的 refresh token,旧的立即失效。

text
无 rotation:
  refresh_token_R1 → 换 access_token
  过一会 refresh_token_R1 → 再换 access_token   ← 同一个 refresh 反复用,泄露即长期失守

有 rotation:
  refresh_token_R1 → 换 access_token + refresh_token_R2(R1 立即失效)
  refresh_token_R2 → 换 access_token + refresh_token_R3(R2 立即失效)
  ...

Rotation 怎么发现 token 被偷

这才是 rotation 的精髓,不只是"换发新 token"这么简单。

假设攻击者偷到了 R2(合法用户的 refresh token)。接下来发生的事:

text
合法用户:R2 → 换到 access + R3   (R2 现在已失效,但攻击者手里还有 R2 的副本)
攻击者:R2 → 想换 token?         (R2 已失效,服务器拒绝)

到这一步,攻击者已经用不了了。但 rotation 还能更进一步——检测到 R2 被重复使用,从而推断出"token 被偷了":

text
合法用户:R2 → 换到 access + R3
攻击者(手慢):R2 → 拿去换       ← R2 已经被用过一次换出了 R3,现在第二次出现

服务器发现:R2 是个"已经被消费过的" refresh token,它居然又被提交了!
         → 这只能说明 refresh token 链条被分叉了 → 有人被偷了
         → 立即作废整条链(R3、R4... 全部失效),强制用户重新登录

这就是 Refresh Token Reuse Detection(重用检测)。它把 token 旋转从一个"减少窗口期"的措施,升级成了一个"主动发现窃取"的报警器。OAuth 2.0 Security BCP 把这套(旋转 + 重用检测)列为公开客户端的推荐默认行为。

Rotation 的代价与边界

不是没有成本:

  • 每次刷新都要写库(更新 refresh token 的状态、失效旧的)。高频刷新场景下,这会给 token 存储带来写压力,要用 Redis 而不是数据库。
  • 并发刷新的竞态:同一个用户的多个 tab 同时发现 access 过期,同时发 /api/auth/refresh,会出现"第一个成功了把 R1 旋转成 R2,第二个拿着 R1 来换被拒"。解法:前端加请求去重(同一个刷新 Promise 多个 tab 共享),或者授权服务器做短暂的宽限期(R1 在被旋转后的 N 秒内仍接受一次,并旋转到同一个 R2)。
  • 不是所有 IdP 都支持 reuse detection。Auth0、Authing、Keycloak(较高版本)、Logto 支持;自建 OAuth 服务端要自己实现。
  • 机密客户端(confidential client,有后端 secret)未必需要旋转。如果 refresh token 本身就锁在服务端 session 里、从不经过公开通道,rotation 的边际收益不大。rotation 主要是给公开客户端用的。

一个最小可跑的旋转实现

如果你自建 token 服务端,旋转的核心逻辑就几十行:

js
// tokenRotation.js —— 服务端 refresh + 旋转 + 重用检测
import { randomBytes } from 'node:crypto';
import { redis } from './redis';

const REFRESH_TTL = 60 * 60 * 24 * 30; // 30 天
const CHAIN_PREFIX = 'refresh_chain:';

export async function rotateRefreshToken(oldRefreshToken, userId) {
  // 1. 查旧 token 的状态
  const tokenKey = `rtok:${oldRefreshToken}`;
  const meta = await redis.hgetall(tokenKey);

  if (!meta || meta.status !== 'active') {
    // 1a. 这个 token 要么不存在,要么已经被旋转过(status=rotated)
    if (meta && meta.status === 'rotated') {
      // ⚠ 重用检测:一个已经被旋转掉的 token 又被提交 → 链条被偷了
      // 立即作废整条链,强制重新登录
      await redis.del(`${CHAIN_PREFIX}${meta.chainId}`);
      throw new TokenTheftError('refresh token reuse detected, chain revoked');
    }
    throw new InvalidTokenError('refresh token not found');
  }

  // 2. 签发新 access token + 新 refresh token
  const newAccessToken = signAccessToken(userId, /* ttl */ 15 * 60);
  const newRefreshToken = randomBytes(32).toString('base64url');

  // 3. 旧的标记为 rotated(保留一段时间用于重用检测,然后过期)
  await redis.hset(tokenKey, 'status', 'rotated');
  await redis.expire(tokenKey, 60 * 60); // 1 小时后清掉

  // 4. 新的写进去,挂到同一条链上
  await redis.hset(`rtok:${newRefreshToken}`, {
    userId,
    chainId: meta.chainId,
    status: 'active',
  });
  await redis.expire(`rtok:${newRefreshToken}`, REFRESH_TTL);

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

核心是三件事:旧 token 不删而是标记 rotated(为了能识别重用)、新 token 挂到同一个 chainId(为了能整链作废)、检测到重用就把整条链干掉。这套逻辑 Auth0 等商用 IdP 内部就是这么干的,你自建时照着抄就行。

六、撤销与黑名单:Token 失效的工程实现

用户点"登出"、改了密码、被封号、refresh token 被检测到窃取——这些场景都要求 token 立即失效,不能等它自然过期。这一节讲怎么落地。

两种 token 的撤销难度完全不同

  • Refresh token 撤销极容易:它本来就是"来找我要新 token"的,我只要在服务端把它标记为失效,下次它来换 token 就拒绝。本质是有状态的,撤销就是删一条记录。
  • Access token 撤销极难:尤其是 JWT,它是无状态自包含的,服务端验签后信任 payload,没有"查一下还有没有效"的步骤。在 exp 之前,JWT 本身永远"有效"。

所以工程上的策略是:接受 access token 在自然过期前无法真正撤销,用短寿命(15~30 分钟)把窗口期压到最小;对 refresh token 做强撤销,斩断续命能力。用户"登出"的实际语义是:清掉前端 access token + 服务端撤销 refresh token。攻击者如果手里还有那个 access token,他能用到过期——但也就是十几分钟。

JWT 撤销的三种工程手段

如果你必须在 access token 过期前撤销它(高安全场景,比如检测到攻击、账号被封),有三个选择,代价递增:

1. 短 exp,不做事。 最常见,也最省事。接受"最多 15 分钟延迟撤销"的事实。绝大多数 C 端产品这个策略就够了。

2. 黑名单(blacklist)。 维护一个"已撤销但未过期"的 token ID 列表(存 Redis),验签后多查一次。命中黑名单就拒。

js
// blacklist.js —— JWT 黑名单中间件
export async function authGuard(req, res, next) {
  const token = bearerOf(req);
  const payload = verifyJwt(token);             // 验签 + exp
  const blacklisted = await redis.get(`bl:${payload.jti}`);
  if (blacklisted) return res.status(401).end();
  req.user = payload;
  next();
}

export function revokeToken(payload) {
  // 黑名单 TTL = token 剩余寿命,过期后自动清掉,不占空间
  const ttl = payload.exp - Math.floor(Date.now() / 1000);
  if (ttl > 0) redis.set(`bl:${payload.jti}`, '1', 'EX', ttl);
}

关键点:jti(JWT ID)必须在签发时带上;黑名单 TTL 设成 token 剩余寿命,过期自动清理,不会无限膨胀。代价是 JWT 从纯无状态变成"半有状态",每次请求多一次 Redis 查询(1ms 级,通常可接受)。

3. Token 版本号(token versioning)。 给每个用户存一个 tokenVersion,签 JWT 时把它写进 payload。改密码、强制下线就 tokenVersion++。验签后比对 payload 里的版本和库里的版本,不一致就拒。

js
// 签发
const jwt = sign({ userId, v: user.tokenVersion });

// 验证
const payload = verify(jwt);
const user = await db.user.findById(payload.userId);
if (payload.v !== user.tokenVersion) return res.status(401).end();

版本号方案的好处是改密码 = 一键吊销该用户所有设备上的 token(版本一加,老 token 全废),不用维护黑名单列表。坏处是每次验证都要查用户表,跟黑名单一样破坏无状态。它和黑名单是互补的:版本号适合"整用户级吊销",黑名单适合"单个 token 吊销"。

撤销端点(revocation endpoint)

OAuth 2.0 定义了标准的撤销端点(RFC 7009):POST /oauth/revoke,客户端把 refresh token(或 access token)发过去,授权服务器把它作废。NextAuth、Auth0、Keycloak 都实现了它。"登出"时后端调一下 /oauth/revoke 把 IdP 侧的 refresh token 也干掉,才是完整的登出——不然你自家 session 清了,IdP 那边的 refresh token 还活着,下次 OIDC 登录是无感静默登录的,等于没登出。

七、实战:NextAuth / Auth.js 的 PKCE 配置

前面讲了一堆原理,这一节落到代码。NextAuth v5(也就是 Auth.js)在 2026 年已经是 Next.js 生态的事实标准。它默认就启用了 PKCE,但很多人不知道怎么核对、怎么调,出问题不知道往哪看。给一套生产级配置。

ts
// auth.ts —— NextAuth v5 (Auth.js) 生产级配置
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import type { Provider } from 'next-auth/providers';

const providers: Provider[] = [
  GitHub({
    clientId: process.env.AUTH_GITHUB_ID,
    clientSecret: process.env.AUTH_GITHUB_SECRET,
    // NextAuth 对所有 OAuth provider 默认就走 authorization_code + PKCE
    // 如果想显式校验,检查跳转 URL 里有 code_challenge + code_challenge_method=S256
  }),
  Google({
    clientId: process.env.AUTH_GOOGLE_ID,
    clientSecret: process.env.AUTH_GOOGLE_SECRET,
    authorization: {
      params: {
        prompt: 'consent',         // 每次都弹同意,拿到新的 refresh_token
        access_type: 'offline',    // Google 要求 offline 才发 refresh_token
        scope: 'openid email profile',
      },
    },
  }),
];

export const { handlers, auth, signIn, signOut } = NextAuth({
  trustHost: true,                 // 生产必须开,否则 redirect_uri 拼不对
  secret: process.env.AUTH_SECRET, // 加密 cookie/JWT 用,务必是 32+ 字节随机串
  providers,
  session: { strategy: 'jwt' },    // Serverless 友好;有数据库可选 'database'
  cookies: {
    // 服务器组件里直接读 session,不用额外加 provider
    // httpOnly + Secure + SameSite=Lax 是默认,这里显式写出来便于审计
    sessionToken: {
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },
  callbacks: {
    // 把 refresh token 写进 jwt,回调时续期用
    async jwt({ token, account, user }) {
      // 首次登录:account 里有 OAuth 返回的 tokens
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at * 1000; // ms
        token.userId = user.id;
      }
      // 未过期:直接返回
      if (Date.now() < token.expiresAt - 60_000) {
        return token;
      }
      // 过期了:用 refresh token 续期
      if (token.refreshToken) {
        return refreshAccessToken(token);
      }
      return { ...token, error: 'RefreshTokenExpired' };
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.error = token.error;
      return session;
    },
  },
});

Access Token 续期:Auth.js 里怎么做

NextAuth 不自动用 refresh token 续期 access token,得在 jwt 回调里自己写。接上面那段配置:

ts
// auth.ts —— refresh access token
async function refreshAccessToken(token) {
  try {
    // Google 的 token 端点;不同 provider 不同,建议抽一张表
    const res = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: process.env.AUTH_GOOGLE_ID,
        client_secret: process.env.AUTH_GOOGLE_SECRET,
        grant_type: 'refresh_token',
        refresh_token: token.refreshToken as string,
      }),
    });
    const refreshed = await res.json();
    if (!res.ok) throw refreshed;

    return {
      ...token,
      accessToken: refreshed.access_token,
      expiresAt: Date.now() + refreshed.expires_in * 1000,
      // 有些 provider 每次 refresh 会换发新 refresh_token,有些不换
      // 换了就用新的,没换就保留旧的(Google 倾向于不换)
      refreshToken: refreshed.refresh_token ?? token.refreshToken,
    };
  } catch (e) {
    console.error('refresh failed', e);
    return { ...token, error: 'RefreshTokenExpired' };
  }
}

几个容易被忽略的点,也是出 bug 最多的地方:

  • AUTH_SECRET 一定要强。它用来加密你存在 cookie 里的 JWT(里面有 access/refresh token),弱了等于裸奔。openssl rand -base64 32 生成一个。
  • trustHost: true 在生产必须开。否则 NextAuth 会用 NEXTAUTH_URL 拼 redirect_uri,在 Vercel / 反向代理后面经常拼错,报 redirect_uri mismatch。
  • PKCE 是默认的,但要测。发起登录后,F12 看 Network 里那条跳转请求的 query,必须有 code_challenge 和 code_challenge_method=S256。没有就是 provider 配置或版本有问题。
  • state 和 nonce NextAuth 自动处理,你不要自己动。手贱去改反而容易引入 CSRF。
  • 登出要清干净。signOut() 默认清自家 session,但 IdP 侧的 refresh token 还在。如果需要彻底登出(GDPR / 高安全),调一下 provider 的 revocation 端点,或走 OIDC 的 RP-initiated logout。

前端怎么用

tsx
// app/dashboard/page.tsx
import { auth, signIn, signOut } from '@/auth';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    return (
      <form action={signIn.bind(null, 'github')}>
        <button type="submit">用 GitHub 登录</button>
      </form>
    );
  }

  // session.error === 'RefreshTokenExpired' → refresh 挂了,引导重新登录
  if (session.error === 'RefreshTokenExpired') {
    return (
      <form action={signIn.bind(null, 'github')}>
        <p>登录态已过期,请重新登录。</p>
        <button type="submit">重新登录</button>
      </form>
    );
  }

  return (
    <div>
      <p>欢迎,access token 还在内存里,无需手动管理。</p>
      <form action={signOut}><button>登出</button></form>
    </div>
  );
}

注意 Server Action 里直接用 signIn / signOut,这是 NextAuth 推荐姿势,token 全程在服务端流转,前端 JS 摸不到 refresh token——这正是前面强调的安全模型。如果你的 SPA 是纯客户端框架(非 Next.js),那就需要一个 /api/auth/refresh 后端代理,refresh token 留在 httpOnly cookie,前端只拿短期 access token。

小结

  • PKCE 用 code_verifier / code_challenge 的一次性动态握手,替代公开客户端无法持有的 client_secret,让授权码截获攻击失效。永远用 S256,plain 是历史包袱。
  • 隐式流程必须死:token 暴露在 URL fragment、没有 refresh token、无法认证客户端,OAuth 2.1 和 Security BCP 已经把它从规范里删掉。替代方案是授权码流程 + PKCE,这是 2026 年 SPA 和移动端的唯一推荐姿势。
  • 两个 token 的分工:access token 短期(1530 分钟)、高频使用;refresh token 长期(730 天)、只在续期时走一次网络。refresh token 必须存在 httpOnly cookie,绝不进 localStorage。
  • Refresh Token Rotation + 重用检测是公开客户端的推荐默认行为:每次刷新换发新 refresh token,旧的一次性消费;检测到旧 token 被再次提交,立即整链作废——这能把"被动减少窗口期"升级成"主动发现窃取"。
  • 撤销接受 access token 难以立即失效的现实,用短寿命压窗口;需要强撤销就上黑名单或 token 版本号;登出别忘了调 provider 的 revocation 端点,否则 IdP 侧的 refresh token 还活着,下次静默登录等于没登出。

一句话收尾:OAuth 的安全不在于"用了哪个流程",而在于"把每个 token 放对地方、给每个公开通道配一次性证明、对每次刷新做旋转和重用检测"——细节里全是魔鬼,魔鬼从来不写在 README 里。

Related

相关推荐