登录态 Token 存哪里:Cookie、localStorage 与 SameSite 之后的选择

讨论 token 该存哪里之前,我觉得应该先回答两个更根本的问题:这份凭证有没有人需要在 JS 里读它?它被偷走之后,攻击者能拿它干什么、能干多久? 这两个问题的答案定了,存储位置基本就定了,剩下的都是实现细节。

这个月我们在做中后台权限体系的一次改造,顺带把登录态这块从头捋了一遍。起因很朴素:老系统的登录态是后端 session + cookie,新的几个子系统后端换成了 JWT,返回一个 access token 让前端自己保管。于是"保管在哪"就成了一个必须明确写进方案的决定,不能再像以前那样随手 localStorage.setItem('token', xxx) 完事。

关于 XSS 和 CSRF 这两类攻击本身,去年整理浏览器安全的时候已经写过一篇,这里不重复攻击原理,只讨论它们对存储决策的影响。

先摆判断标准:谁要读、丢了多严重

把候选位置摊开,前端能放凭证的地方无非这几个:Cookie(分 HttpOnly 和非 HttpOnly)、localStorage、sessionStorage,以及干脆只放在 JS 内存里。它们的差别可以压缩成两个维度。

第一个维度是 JS 可读性。localStorage、sessionStorage、非 HttpOnly 的 Cookie,JS 都能直接读到;HttpOnly Cookie 和"只在内存"这两种,页面脚本读不到(内存变量虽然在 JS 里,但不落盘、刷新即失,攻击者拿到的窗口极短)。JS 可读意味着一旦页面上有任何一处 XSS——不管是自己代码的漏洞还是引入的第三方脚本出问题——凭证就可能被整段读走。这一点没有商量余地:能被 document.cookielocalStorage.getItem 读到的东西,就要按"XSS 发生时会丢"来做预案。

第二个维度是凭证的生命周期和权限半径。一个五分钟过期、只能换取数据的短效 access token,和一个三十天有效、能签发新 token 的 refresh token,被偷走的代价完全不同。前者丢了,攻击窗口以分钟计;后者丢了,等于把账户交出去一个月。

把两个维度交叉,结论其实很自然:代价大的凭证(refresh token、session id)放 JS 读不到的地方,也就是 HttpOnly Cookie;代价小的凭证(短效 access token)可以放 JS 能读的地方,换取使用上的灵活。 我们最后的方案就是这个组合,下面按实施顺序展开。

localStorage 方案便利在哪、软肋在哪

先说说为什么 localStorage 存 token 会成为很多项目的默认选择,因为它的便利是实打实的。

1// 登录成功后
2localStorage.setItem('access_token', res.data.token);
3
4// axios 请求拦截器里统一带上
5service.interceptors.request.use((config) => {
6  const token = localStorage.getItem('access_token');
7  if (token) {
8    config.headers['Authorization'] = 'Bearer ' + token;
9  }
10  return config;
11});

前端完全掌控:什么时候带、带给哪个域、要不要在某些请求里不带,全在自己手里。跨域调用多个后端服务时尤其省事——Cookie 有域的限制,而 header 里的 token 想发给谁就发给谁。而且它天然免疫 CSRF:跨站发起的请求不会自动带上 Authorization 头,攻击者伪造不了。

软肋就是前面说的 JS 可读。我们的中后台页面上跑着不少第三方脚本:埋点 SDK、客服组件、图表库,还有运营偶尔要求接的活动脚本。这些代码任何一个被投毒或者有漏洞,localStorage 里的 token 就是明摆着的目标。有人会说"那把 token 混淆一下、拆开存",我认为这属于自我安慰——混淆只能防君子,读取逻辑本身就在页面 JS 里,攻击脚本照着执行一遍就拿到了。

所以 localStorage 不是不能用,而是只配得上"丢了损失可控"的东西。五分钟过期的 access token 放这里,即使被偷,攻击者也只有几分钟的窗口,且拿不到续期的能力。

顺带说一下 sessionStorage。它和 localStorage 的 API 一模一样,区别是作用域按标签页隔离、关闭即清空。有同事提议用它存 token,理由是"关了浏览器就没了,更安全"。但这个特性放在中后台场景里是减分项:用户从菜单里"新标签页打开"一个页面,新标签页的 sessionStorage 是空的,登录态直接丢失,要么重新登录、要么做一套跨标签页的 token 传递(有种做法是借 storage 事件在标签页间广播一次性传值,能跑通但相当绕)。而"更安全"其实也有限——它同样是 JS 可读的,XSS 面前和 localStorage 没有本质区别。sessionStorage 的隔离解决的是"多标签页数据互串",不是安全问题,拿它当安全方案是用错了工具。

HttpOnly Cookie:JS 读不到,才是真隔离

refresh token 我们放在 HttpOnly Cookie 里,由登录接口的响应头直接种下:

Set-Cookie: refresh_token=xxx; Path=/api/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000

几个属性都有讲究。HttpOnly 保证 JS 层面读不到,XSS 脚本再怎么翻页面也翻不出这个值;Secure 限制只在 HTTPS 下传输;Path=/api/auth 把这个 cookie 的作用范围收窄到刷新接口那一小片路由——cookie 的 Path 收得越窄,它被无关请求携带、被意外暴露的面就越小。Max-Age 三十天对应 refresh token 本身的有效期。

还有个没写进上面示例、但讨论过的属性是 Domain。我们有两个子系统分别跑在 admin.example.comreport.example.com,如果想让 refresh token 跨子域共享,可以把 cookie 的 Domain 设成 .example.com。这次评审的结论是不共享:cookie 的 Domain 放得越宽,任何一个子域出安全问题都会波及整个域下的凭证,两个系统各自登录一次的代价完全可以接受,没必要为省一次登录把命门连起来。

另一个实际约束是体积。单个 cookie 上限四千字节左右,而且 cookie 会跟着每个同域请求走。JWT 本身不小,我们的 access token 因为塞了权限列表,序列化出来接近三千字节,如果把它也放 cookie,等于每个请求都背着三千字节的头部开销,静态资源请求也不例外。这也是 access token 走 Authorization 头、只在 API 请求上携带的一个附带理由——JWT 越胖,越不适合放 cookie。后端同事后来把权限列表从 token payload 里挪出去、改成单独接口下发,token 瘦身到几百字节,但携带方式没有再改回去。

登录页上那个"记住我"的勾选框,在这套体系里也有了明确的落点:勾选与否不该影响 access token,它影响的是 refresh token 的 Max-Age——勾了给三十天,不勾就发一个会话级 cookie(不写 Max-Age,关浏览器即失效)。以前 session 时代这个选项是后端 session 有效期的事,换成 JWT 之后语义没变,只是载体从 session 记录换成了 cookie 属性。

要说明的是,HttpOnly 防的是"读取",不是"使用"。XSS 脚本虽然读不到这个 cookie 的值,但它跑在你的页面上,照样可以直接调刷新接口,浏览器会乖乖带上 cookie,等于攻击脚本能"借用"你的登录态干活。所以 HttpOnly 不是让 XSS 无害,而是把损失从"凭证被带走、离线长期滥用"降级为"只能在受害者页面存活期间在线作恶"。这个降级很值,但别把它当成银弹。

SameSite 默认 Lax:今年绕不开的新背景

既然用了 Cookie,CSRF 就回到了桌面上。今年这个话题有个新背景:Chrome 80 从二月份开始,把没有显式声明 SameSite 属性的 cookie 默认按 SameSite=Lax 处理,同时要求 SameSite=None 的 cookie 必须带 Secure。这是浏览器层面对 CSRF 的一次大范围收紧,中间还因为疫情期间怕影响必要服务,Chrome 在四月份临时回滚过一阵,七月份的新版本又重新推上来,所以这几个月社区里关于 SameSite 的讨论就没停过。

我们上半年也确实被它"教育"过:有个内嵌在别的系统 iframe 里的老页面,登录态突然丢了,排查下来就是 cookie 没声明 SameSite、被新默认值拦在了第三方上下文之外,后端补了 SameSite=None; Secure 才恢复。当时测试环境还有个插曲——测试环境是 http 的,Secure 属性导致 cookie 根本种不上,最后是给测试环境单独配了套不带 Secure 的下发逻辑才让 QA 能正常回归。

对本篇的主题来说,这个变化意味着:只要你的 cookie 是同站使用(绝大多数中后台就是),显式声明 SameSite=LaxStrict,CSRF 的攻击面就被掐掉了一大半,传统的 CSRF token 从"必选"降为"纵深防御的加固项"。 Lax 允许顶级导航的 GET 带 cookie,Strict 连这个也不放行。我们给 refresh token 选了 Lax 而不是 Strict,原因是用户从邮件里的链接点进系统时,Strict 会导致首个请求不带 cookie、被误判为未登录,体验上多一次跳转;而刷新接口本身只接受 POST,Lax 下跨站 POST 不带 cookie,防护上没有损失。

当然不能只指望浏览器新政策。存量的老浏览器不认识 SameSite,而且 Safari 和 Firefox 的默认行为跟 Chrome 也不完全一致。刷新接口我们仍然校验 Origin 头:请求头里的 Origin 是浏览器强制附加、页面脚本改不了的,服务端比对一下白名单,成本极低,算是双保险。

顺手记一个排查技巧:Chrome DevTools 的 Application 面板里,Cookies 一栏现在会把因 SameSite 被拦截的 cookie 单独标黄,Network 面板里被拦的请求 cookie 上也有黄色感叹号,鼠标停上去能看到具体原因。上半年查那个 iframe 登录态问题时,就是靠这个提示才没走弯路。

HttpOnly Cookie 方案有个 localStorage 方案没有的麻烦:本地联调。token 在 localStorage 里时,前端想怎么造数据都行;换成 HttpOnly Cookie 之后,本地 localhost:8080 起的 dev server 和测试环境的接口域名不同源,cookie 的种入和携带都会出问题。

我们的解法是让所有请求都走 webpack-dev-server 的代理,让浏览器视角下接口和页面同源:

1// vue.config.js
2module.exports = {
3  devServer: {
4    port: 8080,
5    proxy: {
6      '/api': {
7        target: 'https://test-gateway.internal.com',
8        changeOrigin: true,
9        // 把响应头里 Set-Cookie 的 Domain 改写成 localhost,
10        // 否则测试域名下发的 cookie 本地种不上
11        cookieDomainRewrite: 'localhost',
12      },
13    },
14  },
15};

cookieDomainRewrite 是 http-proxy-middleware 提供的能力,专门处理"上游种的 cookie 域名跟本地不匹配"这个问题。另外测试网关下发的 cookie 带 Secure,而本地是 http,Chrome 会拒收——好在 Chrome 对 localhost 有豁免,视为安全上下文,这一点帮了大忙;但如果有人习惯用 127.0.0.1 或者本机局域网 IP 访问,就会莫名其妙种不上 cookie。联调阶段"登录态时有时无"的问题,十有八九出在 cookie 的 Domain、Secure 和 SameSite 三个属性和本地环境的错配上,这句话我写进了项目的 README,省得每个新同事都撞一遍。

还有一个反模式要单独记一笔:联调不顺的时候,有人提议"要不把 token 拼在 URL 上传过去"。这个口子坚决不能开——URL 会进浏览器历史、服务器访问日志、Referer 头,甚至用户随手一复制就把登录态发给了别人。凭证只该出现在两个地方:请求头,或者 cookie。

Token 刷新与静默续期

存储位置定了,接下来是生命周期。access token 五分钟过期,不可能让用户五分钟登录一次,续期必须是无感的。

我们的做法是在 axios 响应拦截器里统一处理 401:第一次收到 401,先调刷新接口(refresh token 在 HttpOnly Cookie 里自动携带),拿到新的 access token 后重放原请求。这里有个必须处理的并发问题:中后台页面一打开常常并发七八个请求,token 过期时它们会同时收到 401,如果各自都去调刷新接口,就会打出一串重复刷新。解法是用一个共享的 Promise 把刷新请求收敛成一次:

1let refreshing = null;
2
3function refreshToken() {
4  if (!refreshing) {
5    refreshing = axios
6      .post('/api/auth/refresh')
7      .then((res) => {
8        const token = res.data.access_token;
9        localStorage.setItem('access_token', token);
10        return token;
11      })
12      .finally(() => {
13        refreshing = null;
14      });
15  }
16  return refreshing;
17}
18
19service.interceptors.response.use(null, (error) => {
20  const { config, response } = error;
21  if (response && response.status === 401 && !config._retried) {
22    config._retried = true;
23    return refreshToken().then((token) => {
24      config.headers['Authorization'] = 'Bearer ' + token;
25      return service(config);
26    });
27  }
28  return Promise.reject(error);
29});

_retried 标记防止刷新后的重放再次 401 时陷入死循环——refresh token 也过期了就该老老实实跳登录页。这套拦截器上线前 QA 专门造了个场景:让后端把某个账号的 refresh token 直接作废,验证并发请求下页面是干净地跳登录页,而不是弹出七八个"登录已过期"的提示。第一版确实弹了一串,后来把"跳登录页"这个动作也做了收敛才过。

后端同事在方案评审里补了一个我之前没接触过的概念:refresh token 轮换(rotation)。每次刷新不光发新的 access token,连 refresh token 也换成新的,旧的立即作废。这样即使 refresh token 在某个环节泄露,攻击者和真实用户手里的 token 只有一个能用,一旦服务端发现一个已作废的 refresh token 被再次使用,说明发生了泄露,可以直接把整条 token 链吊销。实现成本主要在服务端,前端几乎无感,我们这次一并上了。

另一个改进是别等 401 再刷新。JWT 的 payload 就是一段 Base64URL 编码的 JSON,前端可以直接解出 exp 字段:

1function getTokenExp(token) {
2  try {
3    const payload = token.split('.')[1];
4    // Base64URL 转标准 Base64 再解码
5    const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
6    return JSON.parse(json).exp * 1000; // exp 是秒,转毫秒
7  } catch (e) {
8    return 0;
9  }
10}
11
12function scheduleRefresh(token) {
13  const delay = getTokenExp(token) - Date.now() - 60 * 1000; // 提前一分钟
14  if (delay > 0) {
15    setTimeout(() => {
16      refreshToken().then(scheduleRefresh);
17    }, delay);
18  }
19}

要强调的是这里解析 payload 只是为了读过期时间,前端解出来的 JWT 内容只能当"提示"用,任何权限判断都不能信它——签名校验只发生在服务端,页面上谁都能伪造一个 payload 好看的假 token。主动续期为主、401 重放兜底,两条路都留着,用户就感知不到任何一次失败请求。

多标签页的登录态同步

中后台用户开五六个标签页是常态,登录态在标签页之间不同步会出很怪的问题:A 标签页刷新了 token,B 标签页还拿着旧的去请求;A 退出登录了,B 还在正常操作。

localStorage 在这里反而帮了忙——它有一个天然的跨标签页通知机制,storage 事件:

1window.addEventListener('storage', (e) => {
2  if (e.key === 'access_token' && e.newValue === null) {
3    // 别的标签页退出登录了,本页也跳登录页
4    router.replace('/login');
5  }
6  // e.newValue 有值则是别的标签页刷新了 token,
7  // 下次请求从 localStorage 取到的自然是新值,不用额外处理
8});

要注意 storage 事件只在"其它"标签页触发,修改发起页自己收不到,这个特性正好符合"通知别人"的语义。access token 存 localStorage 的方案里,刷新后的新 token 对所有标签页立即可见,同步问题基本自动解决。

有一个跟主动续期叠加出来的细节:五个标签页各自都挂着 setTimeout 定时器,到点会有五个标签页同时去刷新。有了前面 rotation 机制,重复刷新甚至可能把彼此的 refresh token 刷作废。我们的处理是刷新前先看一眼 localStorage 里 token 的 exp 是不是已经被别的标签页续过了,续过就只重设定时器不发请求;再配合服务端给 rotation 留几秒的宽限期(旧 token 作废后短时间内重放不触发吊销),把这个并发面收掉。多标签页环境下,任何"定时做某事"的逻辑都要假设有 N 个自己在同时跑。

BroadcastChannel 这个更语义化的跨标签页通信 API 我也看了一眼,Chrome 和 Firefox 都支持了,但 Safari 至今没跟上,我们的运营同事里 Mac 用户不少,暂时还是 storage 事件更稳。如果 token 只存在各页的 JS 内存里,跨标签页同步就得完全靠这类信令自己搭,这也是我们没有选"纯内存"方案的主要原因——它防 XSS 最彻底,但刷新丢失、多标签互通的工程成本,对一个内部中后台来说不划算。

退出登录要做干净哪些事

最后是退出。以前的实现就一行 localStorage.removeItem('token') 加跳转,这次梳理时列了列,一个干净的退出至少包含四件事,前后端各两件。

1function logout() {
2  // 1. 通知服务端作废 refresh token,并清 HttpOnly Cookie
3  return axios.post('/api/auth/logout').finally(() => {
4    // 2. 清本地凭证(顺带通过 storage 事件通知其它标签页)
5    localStorage.removeItem('access_token');
6    // 3. 清内存态:Vuex 里的用户信息、权限表、路由表
7    store.commit('user/RESET');
8    resetRouter(); // 重置 addRoutes 动态注入的路由
9    // 4. 跳转
10    router.replace('/login');
11  });
12}

第三步是我们真实踩过的坑:只清了 token 没清 Vuex,下一个账号在同一个标签页登录后,菜单闪现了上一个账号的权限,因为动态路由是用 router.addRoutes 注入的、不会随 token 消失。Vue Router 3.x 没有提供移除路由的 API,通行做法是重新 new Router() 换掉内部的 matcher,resetRouter 干的就是这个。

服务端那边的动作前端替代不了:把 refresh token 作废(配合 rotation 的存储,这一步就是删一条记录),并通过 Set-CookieMax-Age=0 清掉 HttpOnly Cookie——HttpOnly 的 cookie JS 删不了,必须靠服务端响应头。只做前端清理的"退出"是假退出:refresh token 还活着,捡到这台电脑的人从网络记录里翻出它就能续命。 尤其我们的用户里有共用工位电脑的客服团队,这一条是安全评审时被明确要求的。注意上面代码里用的是 finally 而不是 then:登出接口本身可能因为 token 已过期而返回 401,本地清理不能被这个失败挡住。

退出还有个容易漏的角落:浏览器的"恢复上次会话"功能。用户不点退出、直接关浏览器,下次打开时标签页全部恢复,localStorage 里的 access token 多半已过期,但 refresh token 的 cookie 还在,静默续期会自动接上——这是符合预期的"记住我"行为。反过来,如果这台机器是公用的,就只能靠管理端的"强制下线"能力兜底,前端做不了更多。

顺带把 JWT 无状态带来的一个现实问题记下来:access token 签出去就收不回,登出后它在剩余的几分钟内理论上仍然有效。我们的取舍是接受这个窗口——五分钟的暴露期换来服务端不用维护 token 黑名单。如果哪天业务上出现"必须立即踢下线"的需求(比如强制下线某个被盗账号),再考虑给网关加一层短 TTL 的黑名单缓存,目前没有为了理论完备性提前付这个成本。

新老系统并存期的登录态桥接

方案里最后一块硬骨头跟存储本身关系不大,但真实项目绕不开:老系统还是 session + cookie,新子系统是 JWT,改造是渐进的,用户会在两套体系之间来回跳。总不能让用户从老系统点进新子系统时再登录一次。

我们的做法是在网关层做一次"凭证兑换":新子系统首次加载时,如果本地没有 access token,先调一个 /api/auth/exchange 接口,这个请求会自动带上老系统的 session cookie(同站,Lax 不拦),服务端校验 session 有效后,按前面的规则签发一对新凭证——access token 走响应体、refresh token 走 Set-Cookie。前端把这段逻辑收在路由守卫里:

1router.beforeEach(async (to, from, next) => {
2  if (whiteList.includes(to.path)) return next();
3
4  let token = localStorage.getItem('access_token');
5  if (!token) {
6    try {
7      // 尝试用老系统 session 兑换新凭证
8      const res = await axios.post('/api/auth/exchange');
9      token = res.data.access_token;
10      localStorage.setItem('access_token', token);
11    } catch (e) {
12      // 老 session 也没有,才是真的未登录
13      return next('/login?redirect=' + encodeURIComponent(to.fullPath));
14    }
15  }
16  next();
17});

反方向(新系统跳老系统)不用前端做事,老系统的 session cookie 一直都在。真正麻烦的是登出的一致性:用户在新子系统点退出,老系统的 session 要不要一起销毁?产品的答案是"要,用户理解里这是同一个系统"。于是登出接口在服务端串联了两件事:作废 refresh token、销毁 session。并存期的登录态设计,难点从来不是两套凭证怎么各自工作,而是它们的建立和销毁要在用户视角下表现成同一件事。 这一块的联调花的时间比存储方案本身还多。

回到那两个问题

方案落定之后回头对照开头那两个判断标准,整套设计其实就是它们的展开:access token 需要被 JS 读(要塞进 Authorization 头、要跨标签页共享),且丢失代价被五分钟有效期压到很低,所以放 localStorage;refresh token 没有任何理由让 JS 读到,丢失代价又最大,所以放 HttpOnly + Secure + SameSite=Lax 的 Cookie,配合轮换机制把泄露的伤害再压一层。续期、同步、退出,都是围绕这两份凭证各自的生命周期做的配套。

下半年这套方案会随着新子系统逐个上线接受检验,尤其是 refresh token 轮换在弱网环境下会不会出现"新旧 token 都失效"的边界情况,QA 那边已经在设计用例了。有结论了再补一篇。