
那晚十一点前端同事在群里甩了一张报错截图红色英文特别扎眼your access token could not be refreshed. please log out and sign in again.我第一反应不是看代码而是追问了一句“你这个 access token 存在哪里”他愣了一下说存 localStorage 啊。我叹了口气——这基本就是问题的答案了。其实那天晚上真正让我失眠的不是这个报错本身而是随后他在群里补的一个问题“对了CSRF 防护里也会用到 token这个 token 跟我们说的 access token 是同一个东西吗属于哪个类”这个问题看着基础但能一句话问清楚的开发者说实话不多。我在不少项目里见过把 access token 当 CSRF token 用的也见过把 refresh token 当万能钥匙传的更见过被 CSRF 打了还不知道怎么回事的。今天干脆把这两个问题摊开讲明白access token 和 refresh token 到底有什么区别刷新失败时到底发生了什么以及 CSRF 防护里的那个 token 在分类上到底属于哪一类跟身份认证 token 又是什么关系。1. access token 与 refresh token 的分工逻辑这不是“大小号”而是两把不同用途的钥匙1.1 为什么只发一个 token 不行先说一个最直观的场景你登录了一个网站服务器确认“这个人是谁”之后给你发了一张通行证也就是 token。之后你每次操作都带上它服务器看到这张通行证就放行。这个通行证就是 access token。但问题来了如果这张通行证永久有效一旦被人偷走小偷一辈子都能冒充你。所以通行证必须设定有效期比如 30 分钟。但 30 分钟一到你访问一下就要重新登录体验非常糟糕。于是就有了第二张卡refresh token。它是一张“补卡凭证”不直接让服务器认你是谁而是允许你在 access token 过期后凭它去换一张新的 access token省去重新输入账号密码的麻烦。所以 refresh token 的有效期通常很长比如 7 天、30 天甚至可以做成可续期的“滚动刷新”。这里面最关键的一点很多人理解错了access token 和 refresh token 不是同一个信息的两种写法而是两种完全独立的凭证。access token 代表“当前会话的授权结果”refresh token 代表“长期会话的续期资格”。1.2 access token 的约束条件越短命越安全因为 access token 会被频繁传输理论上被截获的概率也更高。所以它的每一项设计都在为“缩短暴露窗口”服务有效期短常见的 JWT access token 设置为 15 分钟到 2 小时。自包含常见做法是用 JWT把用户 ID、角色、过期时间直接编码进 token服务端不用查数据库就能验证签名和解码。但也正因如此JWT 一旦签发想在到期前让它“立即失效”是很难的——除非引入黑名单或短有效期的策略。权限范围最小化access token 只携带当前会话所需的权限不要把一个能删库的 scope 塞进 token 里。很多团队把 access token 的过期时间设成一整天甚至一周美其名曰“提升用户体验”。我个人非常不建议这么做。你省下的那点重新登录的时间可能换来的是用户 cookie 被拖走后一整周的裸奔成本。与其拉长有效期不如把刷新流程做顺滑。1.3 refresh token 的权限边界它能换新 token但它不该等同于账号密码refresh token 的本质是“离线续期凭证”它的权限边界在于只能去 token 端点兑换新的 access token不能直接访问业务接口。这一点必须在服务端强制校验——刷新接口只能接受 refresh token业务接口只能接受 access token。但实际项目里我见过不少反面教材有人把 refresh token 直接放在 Authorization Header 里去请求业务数据也有人把 access token 用于刷新接口。这些做法一旦被人把玩起来等于把两把钥匙捅进同一个锁孔不仅混淆了职责还扩大了攻击面。refresh token 的另一个关键属性是“可撤销性”。access token 因为是自包含的撤销成本高但 refresh token 通常需要在服务端数据库或存储里留一条记录。一旦检测到异常、用户改密、被踢下线就直接把这条记录删掉这个 refresh token 就废了。这也是热词里 “your access token could not be refreshed because your refresh token was revoked” 的含义——你 access token 过期了想用 refresh token 续结果 refresh token 先被撤销那自然续不动只能重新登录。2. 刷新机制拆解过期、撤销和“无法刷新”的真实场景2.1 标准刷新流程长什么样一次正常的刷新流程通常长这样客户端用 access token 请求业务接口收到 401。客户端检测到 401不提示用户而是带上 refresh token 请求/auth/refresh。服务端校验这个 refresh token 是否有效、是否未被撤销、是否关联到当前用户。校验通过后签发新的 access token有时也会同时签发新的 refresh token。客户端用新 token 重放刚才失败的请求。写成伪代码大概是这样async function apiRequest(url, options) { const accessToken getAccessToken(); const res await fetch(url, { ...options, headers: { Authorization: Bearer ${accessToken}, }, }); if (res.status 401) { const refreshed await refreshAccessToken(); if (refreshed) { return fetch(url, { ...options, headers: { Authorization: Bearer ${getAccessToken()}, }, }); } } return res; } async function refreshAccessToken() { const refreshToken getRefreshToken(); if (!refreshToken) return false; const res await fetch(/auth/refresh, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refreshToken }), }); if (res.ok) { const data await res.json(); setAccessToken(data.accessToken); if (data.refreshToken) setRefreshToken(data.refreshToken); return true; } clearTokens(); return false; }这里面有一个容易踩的坑401 不一定都是 token 过期引起的也可能是权限不足。所以严谨一点的做法是区分“未认证”和“无权限”只有 401 才触发刷新流程403 不应该刷新。2.2 “无法刷新”的三个常见根因回到文章开头的报错 “your access token could not be refreshed”我复盘了三次实际排查经历基本都是下面三种情况之一第一refresh token 被服务端撤销了。比如用户在另一个设备点了“登出所有设备”或者改了密码服务端把该用户的所有 refresh token 记录清空。这时旧 refresh token 再拿去换新必然失败。这种设计是合理的提示用户重新登录也没错。第二刷新令牌也被设置了有效期。不少人忽略 refresh token 也有过期时间。比如 access token 有效期 2 小时refresh token 有效期 7 天。如果用户 7 天零 1 分钟没打开应用refresh token 早已过期前端却仍然尝试刷新那自然报“无法刷新”。第三刷新并发导致 token 互相覆盖。这是比较隐蔽的情况。前端同时发出多个请求都收到 401然后并发调了好几次刷新接口。第一次刷新返回了新的 access token 和 refresh token后面几次刷新使用的是已经被替换掉的旧 refresh token服务端要么拒绝要么返回新 token 后把前面的覆盖掉。结果就会出现一个诡异现象明明刚刷新成功下一刻又报“refresh token 已被使用”。解决办法是在前端给刷新操作加一个互斥锁同时只有一个刷新请求在进行其他 401 请求排队等刷新完成后再用新 token 重放。let refreshPromise null; async function refreshAccessToken() { if (refreshPromise) return refreshPromise; refreshPromise doRefreshRequest().finally(() { refreshPromise null; }); return refreshPromise; }2.3 刷新失败后退出登录不该是简单清缓存很多人告诉我前端检测到刷新失败后就直接localStorage.clear()然后跳登录页。其实这里面有讲究如果 access token 还在 localStorage 里而 refresh token 失效了那你应该只清理认证相关的字段而不是把用户的草稿、主题设置、缓存数据也一并清掉。更重要的是在提示“请重新登录”之前最好先明确告知用户这是“登录状态已过期需要重新验证身份”而不是一句冷冰冰的英文报错。用户体验上的差别往往就在这些小地方。另外很多实现里把 refresh token 也放进 localStorage这等于把“长期会话凭证”和“短期访问凭证”放在同一个容易被 XSS 读走的地方。最稳妥的做法是access token 放内存refresh token 放 HttpOnly Cookie。Cookie 设了 HttpOnly 之后JavaScript 就读取不到它XSS 拿不到 refresh token。而 access token 因为要带在请求头里放内存即可页面刷新就丢丢了就让用户走一遍刷新或重新登录可接受。3. CSRF 防护里的 token 到底算哪一类它不是身份证明而是“这是你本人发的请求”的证明3.1 先搞清楚 CSRF 攻击在打什么CSRFCross-Site Request Forgery跨站请求伪造的攻击逻辑和窃取 token 完全不同。攻击者不需要拿到你的 token而是利用浏览器自动携带 Cookie 的行为诱导你在已登录的状态下去访问一个恶意构造的请求。举个例子你登录了银行网站银行网站把你的会话 ID 存在 Cookie 里。然后你访问了一个恶意站点这个站点悄悄向银行网站发起一个 POST 请求transfer?tohackeramount10000。浏览器会自动带上你的 Cookie服务端一检查 Cookie发现“哦你自己发的”就转账成功了。所以 CSRF 的关键漏洞是服务端无法判断这个请求是不是用户在当前页面里有意发起的。Cookie 会自动携带来源不可控服务端只认 Cookie 就出事。现代浏览器防御 CSRF 的手段已经很丰富比如SameSiteCookie可以阻止跨站请求携带 Cookie再比如Origin/Sec-Fetch-Site头可以用来校验请求来源。但服务端仍然经常需要一道主动校验——这就是 CSRF token 的用武之地。3.2 说清楚CSRF token 属于“请求真实性令牌”不是“访问令牌”标题里的问题“在 CSRF 防护措施里这个 token 属于哪个类 token” 直接回答CSRF 防护中使用的 token分类上叫CSRF token / 防伪令牌anti-CSRF token有的地方也叫会话绑定令牌session-bound token。它既不是 access token 也不是 refresh token它不属于身份认证令牌体系而属于请求完整性验证令牌。它的核心价值不是“证明你是谁”而是“证明这次请求是当前会话主人主动构造的”。常见的三类实现方式是这样的方案工作原理token 类型同步令牌模式Synchronizer Token Pattern服务端生成随机 token绑定 session下发给页面页面提交请求时带上这个 token服务端比对 session 里存的那份会话绑定随机令牌双重提交 CookieDouble Submit Cookie客户端生成随机 token一边写进 Cookie一边放在请求参数或 Header服务端校验两者是否一致无状态随机令牌自定义请求头 Bearer Token要求请求必须携带自定义 Header如X-Requested-With或Authorization并依赖 CORS 预检来阻止跨域伪造自定义头校验型不完全是 CSRF token重点来了不要把 access token 当作 CSRF token 来使。很多团队会把 JWT access token 塞进 Cookie然后说“反正服务端要校验 tokenCSRF 就防住了”。这是想当然。如果 access token 放在 Cookie 里浏览器会自动携带它攻击者照样可以构造跨站请求服务端再怎么校验 token只要 token 自动被带过来就依然会被伪造。同源策略和 SameSite 属性或许能拦截一部分但那不是 token 本身的功劳。CSRF token 必须是攻击者无法预料、也无法自动携带的随机值。它通常由服务端生成并保存在 session 里或者在 Double Submit 模式下由客户端生成但服务端校验一致性。它跟用途单一、格式通常为随机字符串和 JWT 那种自包含、共享密钥签发的方式是完全不同的设计思路。3.3 用 Authorization Header 里的 token 做“准 CSRF 防护”是怎么回事很多人会问那我不用 Cookie把 access token 放Authorization: Bearer xxx里跨站请求不就带不过去了吗确实如果服务端只认 Authorization Header浏览器不会自动帮你加这个头普通 CSRF 攻击者发起的请求是没有这个 Header 的于是被拒绝。这种模式下Authorization Header 承担了部分“请求真实性校验”的功能所以有些人把它也归类为 CSRF 防护的一种形式。但严格来说这不是标准的 CSRF token而是“自定义请求头 认证令牌”的组合。它有防御能力但它在分类上仍然是 Bearer Token。二者的核心差异在于Bearer Token 的价值在于“持有即授权”它属于身份凭证。CSRF token 的价值在于“不确定性”攻击者无法提前预测和放置。如果你把同一个 token 既当身份凭证又当 CSRF token一旦这个 token 因为身份认证的原因被放进 Cookie比如为了方便刷新它的“不确定性”就消失了CSRF 防护也随之失效。所以我一直建议身份认证用一套 tokenCSRF 防护用另一套独立随机值两条线不要混。3.4 CSRF token 的正确使用位置与两套 token 的配合标准做法是这样的用户登录成功后服务端签发 access token 和 refresh token同时下发一个 CSRF token同步令牌模式下绑定 session。前端在提交表单或发 AJAX 请求时把 CSRF token 放在自定义 Header比如X-CSRF-Token。服务端从请求头读取 CSRF token和 session 里存的比对不一致就拒绝。access token 放在内存或 HttpOnly Cookie 里用于身份认证Authorization头照常携带。这里要注意如果 access token 放在 HttpOnly Cookie 里那浏览器会自动携带 Cookie等于回到了“CSRF 需要额外防护”的场景。所以要么别把 access token 放进 Cookie要么就老老实实配 CSRF token。很多框架比如 Spring Security、Django、Laravel 默认都带了 CSRF 防护就是因为他们会在 Cookie/Session 中存放身份信息不能单靠身份凭据防伪造。4. 落地实践存储、刷新与 CSRF 防护的组合方案4.1 三种常见存储方案的取舍下面这张表是我在不同项目里实际用过的方案以及各自的适用场景方案优点缺点适用场景access token 放内存refresh token 放 HttpOnly CookieXSS 难以读取CSRF 压力小刷新逻辑干净页面刷新后 access token 丢失需立即刷新后端渲染的 Web 应用、SPAaccess token 与 refresh token 都放 HttpOnly Cookie前端完全接触不到 token安全性高每次请求自动携带 Cookie必须配 CSRF token传统 Web 应用追求最大化安全access token 放 localStoragerefresh token 放 HttpOnly Cookie前端用起来方便localStorage 有 XSS 泄露风险需配合 CSP 等手段对纯前端团队较友好但要接受风险我一直偏爱第一种。原因不复杂access token 放内存即使页面被注入脚本也只能拿到当前内存中的 token刷新页面就没了。refresh token 放 HttpOnly CookieXSS 读不到。这样既保留了 SPA 的灵活性又避免了把长期凭证暴露给 XSS。但第一种方案有个副作用用户刷新页面时 access token 为空页面不能立刻请求业务数据得先静默刷新一次。前端的 401 拦截逻辑必须写好否则会出现“第一次访问永远要等一个 refresh 请求完成”。4.2 CSRF 防护的完整链条Cookie SameSite 不是银弹现代浏览器默认的SameSiteLax已经挡住了大部分跨站 POST但它仍有绕过的可能比如SameSite默认值是 Lax 时顶层导航的 GET 请求还是会带 Cookie。某些老浏览器也不支持这个属性。所以 CSRF token 仍然是必要的兜底。我在实际配置里一般遵循三个原则服务端对所有非 GET 请求校验 CSRF token而不是只对某些敏感接口校验。因为你不知道哪个接口将来会被用来做坏事。CSRF token 必须放在请求头里不要放在 URL 查询参数里。token 进 URL 容易泄露进日志和 Referer。校验失败时返回 403并记录异常请求方便溯源。同时还要考虑 CORS 策略。如果跨域请求被 CORS 拦截那 CSRF 风险本身会下降很多。但 CORS 不能替代 CSRF 防护因为 CSRF 攻击不依赖读取响应只需要触发请求。4.3 移动端和纯 API 场景要怎么做CSRF 的本质问题来自“浏览器自动携带 Cookie”。在移动端 App 里token 通常由客户端存储不存在浏览器自动携带的行为所以 CSRF 风险天然低很多。但在 WebView 场景下就要小心了——WebView 内部仍然是浏览器内核Cookie 依然会自动带。如果你的 App 内嵌了 H5 页面H5 里的身份仍走 Cookie那照样需要 CSRF token。至于纯 API如果调用方是服务端到服务端那应该用 API Key / mTLS 之类的机器身份认证而不是用户 token。但如果是用户 token 参与的 API那它的分类仍然是 Bearer Token和 CSRF token 无关。提示做纯 API 服务时不要一收到 Authorization Header 就认为请求是安全的。你还得校验请求来源、权限范围、以及 token 是否属于当前资源所有者。认证和授权是两回事。5. 关于 token 安全和刷新机制我踩过的坑与最后的建议5.1 那次把 Bearer Token 当 CSRF token 用的线上事故我之前参与过一个内部系统最初为了减少改动直接把登录返给前端的 access token 塞进 Cookie又在每个请求上让前端带上X-Access-Token头。当时团队觉得“反正请求里带 token 了CSRF 不会中招”。结果一次安全测试别人用一个恶意页面构造 POST 请求浏览器自动带上 Cookie服务端读取 Cookie 里的 access token 一校验通过了。原因就是access token 没有绑定任何“这次请求是从哪个页面发出的”信息它被放进 Cookie 后就成了自动携带的凭证X-Access-Token头根本不会被恶意页面带过去于是服务端毫无招架之力。后来改成access token 放内存 refresh token 放 HttpOnly Cookie CSRF token 走双提交模式之后这个漏洞才算堵上。这件事让我总结出一点任何放进 Cookie 的身份凭证都需要单独设计 CSRF 防护任何用于 CSRF 防护的 token都必须具备“不可预测”和“不自动携带”两个属性。5.2 关于刷新失败提示“请退出重新登录”或许是最好的兜底文章最开始的报错热词实际上是一个典型的刷新链路兜底逻辑提示。它本身没有做错错的是很多开发者在实现时没有区分“refresh token 过期”“refresh token 被撤销”“网络抖动”这三种失败原因。我现在的做法是网络错误静默重试 2 次仍失败再提示网络异常refresh token 过期/被撤销清除认证状态跳转登录页并带一个reasonsession_expired参数让页面显示“登录已过期请重新登录”刷新接口返回 401/403立即退出不再重试。这样用户虽然还是被踢出了但至少他不会看到一个英文的 “your access token could not be refreshed” 而一脸懵。5.3 给开发者的三条可执行清单最后分享一份我自己在项目里经常用来对照的检查清单区分两类 token身份认证走 access token / refresh tokenCSRF 防护走独立的 CSRF token二者不要互相替代。access token 有效期按业务容忍度来建议不要超过 2 小时refresh token 可以设置滚动过期但必须在服务端完成撤销逻辑。凡是放进 Cookie 的 token都要重新审视 CSRF 风险凡是能通过AuthorizationHeader 传递的 token就不要额外放进 Cookie。认证体系本身没有银弹每次新增一个 token 类型就是多暴露一张攻击面。你能做的就是在安全、体验和实现成本之间找到那一组自己项目最合适的组合。这条路上少踩一个坑就是给用户多留一份安心。