做了这么多年后端,Cookie、Session、Token这三个词几乎每天都在打交道。面试的时候它们是高频题,工作里它们是登录认证的基石,可是真遇到线上问题——比如突然冒出一句there is no session with id,或者第三方登录报token exchange failed——不少同学还是会卡壳。
这篇文章我就把三者掰开揉碎讲一遍。不绕理论,直接从原理、对比、实战选型、报错排查四个维度讲透,最后附上我这些年踩出来的经验。无论你是刚接触Web开发的新人,还是被各种认证报错折磨过的老手,看完都能对登录认证体系有个完整的认知,下次再遇到类似问题能直接定位方向。
1. 三个概念先搞清楚:它们分别是什么,解决什么问题
1.1 Cookie:浏览器替你保管的便利贴
Cookie 的本质是一小段文本数据,由服务器通过Set-Cookie响应头下发给浏览器,浏览器把它存下来,之后每次请求同一个域名时,会在请求头里自动带上。
你可以把 Cookie 想象成贴在快递包裹上的便利贴。仓库(服务器)第一次发货时贴了一张纸,上面写着"这个包裹是发给谁的",后续你再来寄件时,仓库不用问你,看便利贴就知道你是谁。这个机制解决的核心问题是:HTTP 协议本身是无状态的,服务器本来记不住"你是谁""你上次干了什么",Cookie 让服务器有了"记忆"的载体。
实际开发中,Cookie 不止用于登录态。搜索历史、语言偏好、埋点标识(比如_ga、_gid),甚至部分电商的购物车,都可能直接写在 Cookie 里。我见过很多团队用它做前端埋点参数传递,把渠道来源、落地页参数都塞进 Cookie,实现跨页面追踪,这也是 Cookie 最常见的非认证用途。
1.2 Session:服务器侧的那本账
Session 和 Cookie 经常成对出现,但两者完全不同。Session 的数据存在服务器端,客户端只保存一个"凭证",这个凭证通常就是 Session ID,而这个 ID 大多数时候通过 Cookie 来传递。
用个类比:Session 是银行保险柜体系。你在银行开户(创建 Session),银行给你一把钥匙(Session ID),你的贵重物品存在银行(服务器内存、Redis、数据库),你每次来取东西都要出示钥匙,银行工作人员通过钥匙找到对应的保险柜。钥匙丢了别人捡到,就能冒领东西,所以 Session ID 必须足够随机、足够难猜。
所以 Session 解决了 Cookie 解决不了的问题:敏感数据不能放在客户端。用户ID、角色、权限这些信息如果直接明文放 Cookie 里,等于把家门钥匙贴在门上,太危险。Session 把这些敏感数据留在服务端,客户端只拿一个随机 ID,安全边界明显清晰得多。
1.3 Token:一张自包含签名的通行证
Token 是一串经过签名的字符串,最常用的是 JWT(JSON Web Token)。JWT 的结构是Header.Payload.Signature,三部分用点号连接,Payload 里可以塞用户ID、过期时间、角色等声明信息,Signature 由服务器用密钥对 Header 和 Payload 签名生成,防止内容被篡改。
Token 的思路像演唱会门票。门票上印着座位号、场次、有效时间,票面上还有防伪码。入场时保安用机器验一下防伪(验签),不用打电话问售票系统(无状态),验过就直接放行。服务器看到 Token 时只需要用密钥验签,就能确认"这个请求确实是合法用户发来的",不需要去数据库查 Session 记录,天然适合分布式和微服务架构。
1.4 一句本质对比:靠什么记住"你是谁"
这三个概念本质都在回答同一个问题:服务器凭什么信任这个请求?
- Cookie 本身不解决信任问题,它只是数据的载体。承载什么内容,取决于服务器怎么设计。
- Session 是服务端存储的会话数据,客户端只保留一个不可预测的 ID,信任建立在"ID 猜不出来"上。
- Token 是把信任关系放进一串自带签名的数据里,服务器验签即可,存储压力为零,信任建立在"签名无法伪造"上。
理解了这一层,后面所有的对比、选型和报错排查都有基础了。
2. 核心区别逐项拆解:存储、生命周期、安全、性能与扩展
2.1 存储位置与大小:客户端 vs 服务端
存储位置是三者的核心分水岭。
- Cookie 存在浏览器里,容量很小,单域名下通常只能存几十个,每个最大约 4KB。
- Session 数据存在服务器,表现为不同的存储介质:单机场景存内存或文件,分布式场景存 Redis、Memcached。Session ID 存在客户端 Cookie 里。
- Token 存在客户端,由开发者决定放哪里:
localStorage、sessionStorage、内存变量或 Cookie 都行。Token 本身大小不受浏览器限制,但随着塞入的声明增多,字符串会越来越大,放 Cookie 时容易挤占 4KB 空间,放 localStorage 则要考虑 XSS 泄露风险。
这里有个很实际的问题:Session 服务端存数据,每来一个用户就占一份内存。我维护过一台 4G 内存的老应用,高峰期在线用户两三万,Session 占的内存肉眼可见地涨。这是后来迁移到 Redis Session 的直接动力。而 Token 方案服务端零存储,把所有压力转移到"验签"这个 CPU 操作上,分布式下更省事。
2.2 生命周期:什么时候失效
Cookie 有Expires和Max-Age两个属性控制过期时间。不设置的话,Cookie 就是会话级 Cookie,关掉浏览器就没了;设置了过期时间,则是持久化 Cookie。
Session 也有超时时间,常见默认 30 分钟。服务端会针对 Session 做空闲超时检测,也就是说,超时时间内没有任何请求,Session 就会被回收。客户端关闭浏览器不会主动通知服务器删 Session,只能等超时。
Token 的过期时间由开发者写在 Payload 的exp字段里,服务端验签时检查时间戳。问题在于,还没到期的 Token 如果想提前作废怎么办?纯 JWT 方案做不到,因为它是无状态的,服务器根本不记得签发了哪些 Token。所以实际项目里往往叠加维护一个"黑名单"或"Token 版本号",这就引入了额外状态。
2.3 传输方式与安全边界
Cookie 的传输是浏览器自动完成的。你设置了HttpOnly,JavaScript 就读不到它,这对防 XSS 攻击非常有价值;设置了Secure,就只会在 HTTPS 连接上发送;设置了SameSite,就能限制跨站请求是否携带 Cookie,对防 CSRF 攻击有帮助。
Session ID 本质上也是走 Cookie 传输,所以继承了上述安全属性。但 Session 方案天生有个软肋——CSRF。因为浏览器会自动带 Cookie,攻击者只需诱导用户访问一个恶意页面,这个页面向目标站点发请求就会自动携带 Session ID,服务器没法区分这个请求是用户主动发起的,还是攻击者伪造的。
Token 的传输则通常放在请求头Authorization: Bearer <token>里。这种模式天然免疫 CSRF,因为自定义请求头无法被跨域请求自动携带,攻击者没法让浏览器帮你把这个 header 填上。但它怕 XSS——如果代码里有漏洞导致脚本能执行,存在localStorage里的 Token 可以被直接偷走。所以很多安全团队推荐:Token 放内存变量,刷新页面后重新获取,虽然体验略差,但攻击面小了很多。
2.4 性能与服务端压力
- Cookie 方案每次请求都带上,大小有限,性能损耗主要在流量上,4KB 在大多数场景忽略不计。
- Session 方案每次请求都要拿着 Session ID 去服务端查。单机查内存快,分布式查 Redis 就有一次网络 RTT;如果 Redis 挂了,整个登录体系就崩溃,所以一般要做高可用。
- Token 方案验签不需要查库,但签名和验签是有 CPU 开销的。尤其用非对称算法 RS256 时,验签计算量比 HS256 大不少。另一个问题是 JWT 无法主动失效,服务器想踢人下线、封禁账号,都比较被动。
更关键的是,Session 天然是"准确"的:用户改密码了、管理员封号了、用户退出登录了,服务端只要删掉 Session,立即生效。Token 做不到,旧 Token 在到期前始终有效。所以涉及权限即时收回的场景,Token 方案必须搭配黑名单或短过期时间。
2.5 一张表直接看整体差异
| 对比维度 | Cookie | Session | Token(JWT) |
|---|---|---|---|
| 数据存储位置 | 客户端浏览器 | 服务端(内存/Redis/DB) | 客户端(localStorage/内存/Cookie) |
| 服务端存储开销 | 无 | 有,按用户量线性增长 | 无,无状态 |
| 传输方式 | 请求头自动携带 | Session ID 走 Cookie | 通常放 Authorization 头 |
| 大小限制 | 单个约 4KB | 取决于服务端存储 | 无硬性限制,但越大越占带宽 |
| 生命周期 | Expires/Max-Age 控制 | 服务端超时回收 | exp 字段控制,难主动提前失效 |
| 典型安全风险 | XSS 读取、CSRF 携带 | Session ID 泄漏、CSRF | XSS 读取、无法踢人、密钥泄露 |
| 分布式扩展 | 不涉及 | 需要共享存储(如 Redis) | 天然支持水平扩展 |
| 主动失效能力 | 可删可改 | 强,删记录立即生效 | 弱,需黑名单或版本号辅助 |
这张表基本就是三者的"体检报告"。日常怎么选,看这张表就能得出方向。
3. 实战选型:具体场景到底用 Cookie+Session 还是 Token
3.1 传统 MVC 服务端渲染:Cookie + Session 依然是最优选
如果你做的是服务端渲染的网站,页面由后端模板渲染,前端几乎没有独立接口层,那 Cookie + Session 依然是最顺手也最安全的方案。
原因在于:浏览器自动带 Cookie,登录成功后服务端redirect到首页,后续请求全部自动携带会话,开发节奏非常快。配套的中间件也非常成熟,Java 的HttpSession、Python Flask 的session、Node Express 的express-session,都是几行代码接入。再加上模板渲染场景下前端代码可控性强,XSS 风险相对低,HttpOnlyCookie 配合 CSRF Token 就构成了一个非常稳固的安全组合。
我接手过一个老项目,所有权限判断都从HttpSession里读当前登录用户。后来做服务升级,唯一要处理的就是把 Session 从单机内存迁移到 Redis,业务代码一行没改,这体现了成熟生态的价值。
3.2 前后端分离和移动端接口:Token 是主流
一旦前后端分离,接口要同时服务 Web 和 App,Token 方案的优势就出来了。App 没有浏览器的 Cookie 自动管理机制,如果硬套 Session,还得手动维护 Cookie 的存取,非常别扭。而 Token 就是一段普通字符串,App 把它存到本地,每次请求手动塞进 header,完全自由。
我做过一个跨 Web 和 App 的项目,最终选了 JWT。流程是:登录接口发放 Access Token(短时效,30 分钟)和 Refresh Token(长时效,7 天);接口校验时只验 Access Token,过期后用 Refresh Token 换新 Access Token。这个设计同时解决"无状态扩展"和"旧 Token 无法作废"两个问题。
前端拿到 Token 后不放 localStorage,而是放内存变量,刷新页面时用 Refresh Token 重新请求 Access Token。这样 XSS 攻击者拿不到长期有效的令牌,Refresh Token 则存在 HttpOnly Cookie 里,进一步减小泄露面。
3.3 分布式与微服务:Session 入 Redis 还是直接全上 JWT
分布式场景的争论是经典话题。Session 方案的解法是"会话共享":把 Session 从各自内存里抽出来,统一放 Redis。用户量再大,Redis 集群也扛得住。Java 生态的 Spring Session 就是干这个的,配置好之后,所有节点共享一份会话数据,登录一次,任意节点都能识别。
JWT 方案的解法是"会话不共享但依然可信":每个服务验签就行,不需要访问中心化存储。横向扩节点的时候,不需要为 Session 复制、失效、同步操心,这在大规模微服务下很香。
怎么选?我个人的判断标准是:
- 系统内已有 Redis,节点数不多,团队对 Session 最熟,那就 Session 入 Redis,省心且能主动踢人。
- 系统是微服务化、接口调用链长、节点动态伸缩频繁,或者要对外开放 API 给第三方,那就 JWT,避免每次请求都命中共享存储成瓶颈。
补充一个折中方案:用 Session 存会话,但通过 Spring Session 的 API 把会话数据序列化成 JWT 下发到前端,服务端同时也存一份,两边都校验。这样既能主动失效,又能在离线时保活。缺点是复杂度上升,除非有特殊需求,一般不建议。
3.4 JWT 实现登录态续签:Refresh Token 与滑动过期
Token 续签是实操中绕不开的环节。常见的机制是双 Token:
- Access Token:短时效,比如 15 分钟到 2 小时,用于业务接口鉴权。
- Refresh Token:长时效,比如 7 天到 30 天,只用于
/refresh接口,换取新的 Access Token。
刷新令牌时,服务端校验 Refresh Token 本身有效,再生成新的 Access Token,同时可选地"轮换" Refresh Token——也就是每刷新一次,旧的 Refresh Token 立即作废,前端拿到新的存起来。轮换的好处是,一旦 Refresh Token 泄露,攻击者最多用一次,之后原合法用户再刷新就会暴露异常,便于服务端感知并吊销整个会话。
滑动过期则常用于 Session 或短期 Token:只要用户持续操作,就自动续期;超过空闲时间才强制下线。体验比较好的是,用户在编辑长文档时不会突然被踢,缺点是攻击者如果一直持有 Cookie 或 Token 持续访问,理论上也能无限续期,需要配合异常检测。
我实际项目的经验是:登录时同时下发放 Access Token 和 Refresh Token,前端只在内存里放 Access Token,Refresh Token 放 HttpOnly Cookie。刷新接口用 Cookie 里的 Refresh Token,换回来后重新覆盖内存中的 Access Token。这套组合的体验和安全性比较均衡。
3.5 第三方登录和开放平台:JWT 几乎是事实标准
还有一个很容易被忽略的场景:第三方登录和开放平台。比如你接微信、Google 登录,或者你作为平台方向第三方开放 API,OAuth 2.0 和 OIDC 的 ID Token 本身就是 JWT。第三方拿到的 token 要传到你的后端换网关签名 token,这时候再做 Session 共享就不合适了,JWT 自包含用户信息的特性非常适合这种跨系统信任传递。
这就是为什么大量"sign-in could not be completed token exchange failed"类报错都出现在第三方登录环节——两个系统间的 token 换发是标准协议,任何一个环节配置不对都会直接报错。
4. 常见报错与排查实录:线上认证问题一次讲透
4.1 There is no session with id:Session 为什么找不到
这个报错非常典型,英文全称类似There is no session with id [xxxx],多出现在 Spring、Hibernate 或某些 Java Web 框架中。表面意思是,服务端根据请求里的 Session ID 查不到对应的 Session 数据。常见原因有三个:
- Session 已被服务端回收。空闲超时到了,或者服务器重启导致内存 Session 清空。
- Session ID 没传过来。客户端把 Cookie 禁了,或者没有设置
cookie支持跨域携带。 - 多节点部署但 Session 没做共享。请求打到 A 节点创建的 Session,下一次被负载均衡路由到 B 节点,B 内存里根本没有这个 session。
排查手段也很直接:抓一下请求看 Cookie 里有没有JSESSIONID;有的话,去服务端看当前存活 Session 列表里有没有对应 ID;确认多节点后看是否配置了 Spring Session + Redis。绝大多数单向问题就出在这三步内。
我遇到过最无语的一次是负载均衡配置了会话保持但超时时间设成了 2 分钟,用户在高并发下被切到不同节点,业务反馈"一会儿登录一会儿掉线"。定位到最后就是 Session 共享缺失,而会话保持只是掩盖了问题。
4.2 Token exchange failed:第三方登录换 token 失败如何排查
token exchange failed: token endpoint returned status 403 forbidden这类报错常出现在 OAuth/OIDC 流程中。流程是:前端拿授权码,后端拿授权码去 Token Endpoint 换 Access Token,Token Endpoint 返回 403,换 token 失败。
常见原因有这几种:
- 授权码已被使用过一次,OAuth 的授权码是单次性的,第二次兑换必然失败。
client_id/client_secret错误或不匹配。- 回调地址和授权请求时的
redirect_uri不一致,这是非常容易被忽略的点。 - 服务器系统时间和授权服务器相差过大,JWT 里的
iat(签发时间)、exp(过期时间)校验会导致请求被拒。 - 部分服务对请求来源 IP 有地域限制,导致返回 403(这也是为什么海外服务的授权失败经常和"country"挂钩)。
排查这类问题,先看授权服务器的原始响应体,开发者通常会看到 JSON 里的error字段,如invalid_grant、unauthorized_client,比在浏览器控制台看一个笼统的失败信息有用得多。再核对回调地址、密钥、时间偏差,基本能覆盖 80% 的 case。
我自己的习惯是先用接口调试工具(Postman 或 Reqable)手动完成一次授权码换 token 的流程,确认参数对,再去查前端集成代码。这样能快速区分是业务代码问题还是配置问题。
4.3 JWT 失效/刷新失败:Access token could not be refreshed 类问题
your access token could not be refreshed这种报错,通常意味着前端拿 Refresh Token 去换新 Access Token 时失败了。可能的原因:
- Refresh Token 过期,这个只能重新登录。
- Refresh Token 被吊销,常见于用户改密码、管理员封禁、或"每次刷新轮换令牌"策略下前端保管了旧令牌。
- 服务端校验 Refresh Token 的签名密钥换了,旧令牌直接无效。
refresh_token字段为空字符串或者格式不对。我自己见过前端的拦截器把基本参数拼错了,导致invalid 'refresh_token': empty string。
根治办法是:前端统一封装刷新逻辑,遇到 401 时只触发一次刷新请求,其他并发请求排队等待新 Token 再重放。刷新失败则直接清除本地登录态并跳转登录页。后端要对 Refresh Token 的吊销有完整的记录,否则用户仍能用旧令牌反复刷新。
4.4 Cookie 被篡改和伪造:用 Burp 改 Cookie 之后发现能越权
安全测试中常见的一个动作:用 Burp Suite 拦截请求,把 Cookie 里的用户 ID 从一个改成另一个,然后放行,如果后端直接信任 Cookie 里的明文用户 ID,立刻就能越权访问他人数据。
这个问题的根源是后端把"标识"和"信任凭证"混为一谈了。Cookie 只是载体,没有签名验证。正确做法是:要么用 Session 替换明文用户标识,要么给 Cookie 值加签名(比如签名成类似 JWT 的令牌)。同时开启HttpOnly、Secure、SameSite三项,防止 XSS 偷取和 CSRF 滥用。
我再多说一句 XSS 和 Cookie/Taken 的关系:XSS 攻击者能在页面里执行脚本,就能读取localStorage里的 Token 或非 HttpOnly Cookie 里的会话。这也是为什么安全评审时,我强烈要求登录态默认放 HttpOnly Cookie 或者内存变量,业务代码再配合 CSP(内容安全策略)缩小脚本注入范围。
4.5 调试工具的认证设置:JMeter 与 Reqable 的实操细节
压测或者接口联调时,工具里的认证设置是很多人的坎。
JMeter 里测登录态接口,最直接的方式是用 HTTP Cookie Manager。添加之后,第一个请求登录成功,响应里的Set-Cookie会被自动管理,后续请求自动携带。如果登录接口返回的校验码是放在响应体而不是Set-Cookie,就要用后置处理器提取,再用BeanShell或JSR223脚本手动写到 Cookie Manager。跑压测时,每个线程组建议独立 Cookie 上下文,避免线程间串号,否则压测结果会出现大量 401。
Reqable 这类调试工具里,常见需求是"把上一个接口响应里的 Cookie 带入下一个接口"。操作思路是用"脚本处理"或者"环境变量"实现:在第一个接口的响应脚本里解析Set-Cookie字段,存到环境变量,第二个接口的 Header 里引用这个环境变量。大多数接口工具的规则引擎都能做到,关键是理解Cookie和Set-Cookie的格式差异,一个是请求头,一个是响应头,别混淆。
5. 几条实操心得:关于认证设计我踩过的坑和最终建议
做认证设计这么多年,我的体会是:没有银弹,只有权衡。Cookie、Session、Token 不是互相替代的关系,而是为了解决不同问题演化出的方案。
第一,能用 Session 的地方别硬上 JWT。如果你有运维 Redis 的能力,且需要主动踢人、封号、权限即时回收,Session 的模型和生态最省心。JWT 的无状态优势在中小系统里往往体现不出来,反而因为"想踢人踢不掉"增加复杂度。
第二,Token 方案一定要处理续签和吊销,否则上线后运营"踢人下线"需求一来就得返工。提前设计好 Access Token + Refresh Token 的刷新链路,配合令牌轮换和黑名单,后端才不会被无限保留的旧令牌绑架。
第三,HttpOnly、Secure、SameSite 这三个属性,在 Cookie 场景下能开就开。我在几个项目里提前把 SameSite 设为 Lax,直接挡掉了一大批 CSRF 风险。Token 放在 localStorage 前,先想清楚你的站点能不能在 XSS 面前完全免疫,多数站点不能。
第四,报错信息真的很有价值。token exchange failed、no session with id、refresh token revoked,这些字符串虽然让人头疼,但排查路径基本都是固定的。把认证链路的时间校准、密钥治理、日志记录做好,线上问题定位时间至少缩短一半。
最后分享一个小技巧:开发环境把 Session 和 Token 的过期时间调长到一天甚至更长,避免调试过程中频繁重新登录;但生产环境保持短有效期,否则一旦泄露,攻击者利用窗口会很大。可以在配置中心里动态调整这两套参数,发布上线时报错率会明显下降。
这套账号认证体系说复杂也复杂,说简单也简单,核心就是搞清楚"信任凭证放在哪、怎么验、什么时候失效"。把这三个问题想明白,无论用哪套方案都不会跑偏。