做Web开发这些年,有个问题几乎每次面试都会被问到,日常开发里也经常因为边界没理清闹出线上事故:Cookie、Session、Token、JWT,这四个词到底什么关系?很多同学能背出几句概念,但一落地就混乱——明明用的Token,为什么还看到Cookie?JWT和Token不是一回事吗?Session是不是已经过时了?
我这次不打算按教科书一条条罗列定义,而是从一张关系图出发,把这四样东西放回它们诞生的场景里讲清楚。我先说个结论:它们根本不是同一维度的东西。Cookie是传输载体,Session是服务端的内存记录,Token是一类凭证的统称,JWT是Token的一种具体实现。把这条主线理顺了,后面所有的面试题、架构选型、踩坑排查,都会变得很简单。
这篇文章不挑读者。刚入门的前端后端、被面试题折磨的求职者、以及想梳理清楚方案选型的全栈开发者,都能从这里拿到一套可以直接用的认知框架和实战参考。我还会穿插一些真实项目里踩过的坑,比如跨域时Cookie被浏览器拦截、JWT失效后的续签方案、接口文档被鉴权挡住怎么放行这类高频问题,这些才是文档里不会写、但线上一定会遇到的东西。
1. 内容整体设计与思路拆解
1.1 为什么这四个概念总是被放在一起
很多人第一次接触这组概念,是在同一个登录功能的代码里。前端存了个什么东西,请求头带了个什么字段,后端从某个地方取出来验一下,这个流程跑通之后,这四个词就全出现了。但它们各自负责的环节完全不一样。
我从实际代码里的位置说起。Cookie出现在HTTP请求和响应的Header里,它的出生地是浏览器,由服务端通过Set-Cookie指令生成并交给浏览器保存。Session从来不会出现在前端代码里,它住在服务端的内存或缓存中间件里,前端只知道一个叫做SessionID的字符串。Token是一个逻辑层面的概念,只要是一串用来证明“我是谁”的凭证,都可以叫Token,不管它存在Header里还是Cookie里。JWT则是Token里最流行的一种结构规范,它把用户信息直接编码进凭证本身,服务端不用查库就能验出身份。
如果画一张关系图,最外圈是HTTP协议,四个概念全部落在它之上;第二层是Cookie和Token这两个携带凭证的通道;第三层中间是Session这样的服务端状态存储,以及JWT这样自带状态的凭证方案。图的中心是同一个问题:在一次无状态的HTTP请求里,服务端凭什么认为请求者还是刚才登录的那个人。
1.2 这张图的主线:HTTP协议天生“健忘”
理解了主线,就理解了这四者为何存在。HTTP协议设计之初是纯粹的请求-响应模型,每个请求都是独立的,服务端处理完一个请求就忘了你是谁。但业务又需要“登录一次,后续请求都认得我”,于是在协议之上叠加了身份识别的机制。
最早的思路是Cookie + Session。服务器第一次收到登录请求后,在内存里记一份用户数据,生成一个唯一编号(SessionID),把编号通过Set-Cookie交给浏览器。浏览器后续每次请求自动带上这个Cookie,服务端根据编号找到内存里的用户数据。这个方案的优点是简单可控,服务端可以随时把某人的会话删掉实现强制下线;缺点是服务端必须保存状态,多台机器部署时要引入共享存储,否则请求落到不同机器上就找不到Session了。
后来出现了Token的思路,核心变化是把“验证”从“查存储”变成“算签名”。服务端不保存任何会话记录,只把一个包含用户信息的字符串算好签名后交给客户端。客户端后续请求带上它,服务端用同一个密钥重新算一遍签名,对得上就放行。JWT就是这种思路的代表,它把用户ID、过期时间、签名都编码进一串字符里,真正做到了服务端无状态。
所以这张图里有一条清晰的演进线:Cookie解决了浏览器怎么保存凭证的问题,Session解决了服务端怎么记住用户的问题,Token解决了服务端不想存状态的问题,JWT解决了Token怎么安全携带信息的问题。它们不是竞争关系,而是层层叠加的解决方案。
1.3 一张表看懂四者对比
| 维度 | Cookie | Session | Token | JWT |
|---|---|---|---|---|
| 本质 | 浏览器存储机制 | 服务端存储机制 | 凭证概念 | 凭证实现规范 |
| 存储位置 | 浏览器本地 | 服务端内存/缓存 | 客户端(任意位置) | 客户端(通常存本地) |
| 是否占用服务端存储 | 不占用 | 占用 | 不占用 | 不占用 |
| 是否自带用户信息 | 不携带,只存ID | 不携带,靠ID关联 | 视实现而定 | 自带,Base64编码可读 |
| 跨域支持 | 受SameSite限制,默认不佳 | 依赖Cookie跨域 | 可放Header,天然支持 | 可放Header,天然支持 |
| 常用场景 | 会话ID传递、偏好记录 | 传统服务端渲染项目 | 前后端分离、移动端 | 前后端分离、微服务鉴权 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:四样东西各自的门道
2.1 Cookie:不只是用来存用户名的
Cookie经常被误解成一种“用户数据存储工具”,实际上它真正的定位是HTTP协议在浏览器侧的补充存储。服务端想发给浏览器的短小文本,都可以通过Set-Cookie塞进去,浏览器会按照域名和路径的规则自动保存,并在后续请求里自动带上。
Cookie里有几个属性直接影响行为,必须知道。Domain决定了哪些域名能收到这个Cookie;Path限定路径范围;Max-Age和Expires控制有效期;Secure要求只有HTTPS连接才发送;HttpOnly禁止JavaScript读取,这是防范XSS窃取会话的关键。最后一个SameSite,它有三个值:Strict严格限制第三方请求带Cookie,Lax允许顶层导航的GET请求携带,None表示允许跨站携带但必须配合Secure。
我在实际项目里不止一次遇到这样的情况:本地联调和线上环境Cookie失效,排查半天发现是缺少Secure属性,HTTP环境下浏览器直接拒收。这里有个很容易踩的坑,Secure的意思不是“更安全”,而是“只走HTTPS”。开发环境没有HTTPS证书,这个属性反而会把Cookie堵死。
2.2 Session:服务端的档案袋
Session的本质是“把用户状态放在服务端”,浏览器只保存凭证编号。这个设计的最大优点是服务端有绝对的控制力——管理员可以把某个用户的Session从存储里删掉,用户立即失效,不需要等过期时间。很多后台管理系统的“踢人下线”功能就是这么实现的。
Session的存储方案是一个演进过程。最早的JavaWeb项目放在HttpSession里,底层是JVM内存;后来并发量上来,多台应用服务器部署,Session必须共享,于是出现了Spring Session配Redis的经典方案;Redis挂了还会引发雪崩,所以大厂通常做Redis集群和内存兜底。我曾经在一个旧项目里看到applicationContext.xml里配了Spring Session的示例,代码是把Session序列化后存进Redis,这种方案的好处是应用重启后用户会话还在。
Session也有它的痛点,最突出的是跨域。浏览器对第三方Cookie的管控越来越严,前端域名和后端域名不一致时,SameSite默认值Lax就会拦截跨站请求携带Cookie,导致开发环境一会儿能用一会儿不能用。我后面会专门讲这个问题的排查过程。
2.3 Token:所有凭证的统称
严格来说,Token不是一个具体技术,而是一类方案的总结。客户端在登录成功后拿到一个凭证,后续每次请求在Authorization头里带上它,服务端验证它,通过就放行。这个凭证就可以叫Token。
Token方案里最关键的是“验证方式”。有的Token是随机字符串,服务端把用户信息和这个串的关系存进Redis,验证时查库——这种叫Opaque Token(不透明凭证),本质上是Session的变体,只是不依赖Cookie。有的Token是自带信息的结构化字符串,比如JWT——服务端不用存储任何会话状态,纯靠算法验签。这两种方式各有各的适用场景,不存在谁绝对更好。
我自己在项目选型时的判断标准很简单:如果业务需要“随时把用户踢下线”,用需要存储的Token,控制力强;如果业务是纯接口服务、没有强管控需求,用JWT这类无状态Token,扩展性和并发能力更好。误把JWT当成可以随便踢人下线的方案,是很多初学者的认知盲区——JWT在不引入黑名单机制的情况下,一旦签发就无法在过期前作废。
2.4 JWT:自带签名的结构化凭证
JWT的全称是JSON Web Token,它把用户信息经过JSON序列化后做Base64编码,再附上数字签名,最终拼成三段式字符串:Header.Payload.Signature。第一段的Header里声明了算法类型,第二段Payload里放业务需要的字段,比如user_id、role、exp过期时间,第三段是用指定算法对前两段内容加上密钥生成的签名。
JWT最大的卖点是“自包含”。服务端拿到Token后,只要用同一个密钥验一下第三段签名是否匹配,就能确认前两段内容没有被篡改过。用户ID、角色、过期时间都在Token里,不需要去Redis查任何数据,这就是它支撑高并发和水平扩展的底气。但代价也很明显:因为自包含,Token一旦泄漏,在过期前任何人都能冒充用户;因为不需要存储,服务端无法主动让某个Token失效;Baes64只是编码不是加密,Payload里的任何信息任何人用在线解析工具都能直接看穿。
这就带出了JWT一个非常关键的坑——不要在Payload里放密码、手机号、身份证这类敏感信息。很多人会把用户信息一股脑塞进JWT,觉得签名很安全,其实只要复制粘贴到任何一个JWT在线解析网站,就能看到明文。我在项目中一直坚持的原则是:JWT里只放用户ID和角色这类不影响安全的信息,其他敏感数据要么不存,要么加密后再存。
3. 从原理到落地:典型场景怎么选
3.1 传统服务端渲染项目
如果你的项目是JSP、Thymeleaf这类服务端渲染页面,页面跳转都发生在浏览器和服务端之间,没有独立的API服务,那最合适的方案就是Cookie + Session,不需要绕弯子。
这个组合在常规场景下表现最稳定。服务端生成SessionID写进Cookie,浏览器自动携带,服务端查Session验证,流程简单、社区资料多、遇到问题好排查。配合Spring Session把会话存进Redis,多实例部署也不怕。
选这个方案的前提是:页面和服务端同域部署,不存在跨域问题。如果前端页面和后端服务拆分到两个域名,Cookie + Session的跨域短板会立刻暴露,需要处理烦琐的SameSite=None; Secure配置,这时候就要考虑换方案了。
3.2 前后端分离项目
前后端分离是目前SPA项目的主流形态,前端跑在独立域名,后端只提供JSON接口。如果还用Cookie方案,跨域配置和浏览器策略会让你苦不堪言。这里应该优先选Token方案,把Token放在请求头Authorization里,不依赖Cookie的自动携带,天然规避了跨域Cookie的限制。
现在有两种主流实现路径。一种是用随机Token,登录成功后把token和用户信息存入Redis,设置过期时间,客户端每次请求带着,服务端先从Redis查是否存在,存在就放行。这种方案的优点是随时可以删掉Redis中的记录实现强制下线,缺点是每次请求都要多一次Redis查询。另一种是用JWT,服务端无状态,验签通过就放行,部署和扩展更轻松,但无法主动失效。
我做过的一个管理后台项目,一开始用JWT,后来产品提了个需求:管理员可以强制重置某个用户的密码并让该用户的所有会话立即失效。这个需求如果用纯JWT,得引入Token黑名单机制,否则只能等过期。后来我把方案改成了“JWT + Redis黑名单”的折中模式:JWT照常签,签发时将jti标记写入Redis,设置为Token的过期时间;退出登录或强制下线时把jti加入黑名单,鉴权时先查黑名单再验JWT。既保留了无状态扩展的优势,又弥补了不能主动失效的短板。
3.3 微服务与第三方授权场景
微服务架构和第三方开放平台通常不只一个服务,前端请求要经过网关转发到多个后端服务。如果每个服务都去Redis查Session,耦合度高且延迟大;JWT的无状态特性正好适合这种场景——网关统一校验签名,后续服务直接信任Token里的用户信息,不需要依赖共享存储。
第三方授权里,最典型的Token是OAuth2体系下的Access Token和Refresh Token。Access Token有效期短(通常几十分钟),用来访问资源;Refresh Token有效期长(可能几天甚至几周),专门用来换取新的Access Token。这样做是为了降低长生命周期凭证泄漏的风险,同时保证用户体验——用户不需要反复登录,后台用Refresh Token静默续期。
我见过有的团队把这两个Token都设计成JWT,结果Refresh Token不设失效控制,泄漏后能持续续期,相当危险。规范的做法是Refresh Token只生成随机串并存在服务端,不允许前端读取,仅在换发Access Token时使用。类型边界一旦划清楚,架构整体就稳定了。
3.4 移动端与API服务
App端没有浏览器,自然没有Cookie自动携带的机制,但HTTP客户端(比如OkHttp)也支持Cookie持久化。我不建议在App里沿用Session方案,一是原生客户端的Cookie行为不如浏览器统一,二是弱网环境下的会话保持需要额外的状态同步。
移动端更通用的做法是:登录后把Token存在App本地安全存储(iOS的Keychain、Android的EncryptedSharedPreferences),请求时从存储取出放入Authorization头。JWT在这里有天然优势,因为App和服务端之间没有浏览器中间层干预,Token的携带完全受控,只要做好App端的存储安全,整个链路比Web端清晰得多。另外,移动端的弱网和请求重试场景里,JWT的自包含性让服务端即使短暂不可用也能在恢复后快速验证,不会因为Session丢失而让用户被迫重新登录。
4. 实操过程与核心环节实现:以Spring Boot + JWT为例
4.1 项目搭建与依赖准备
前面把理论讲清了,现在直接上一套能运行的代码。我用Spring Boot实现一个最小可用的JWT登录鉴权流程,包含生成Token、校验Token、拦截需要登录的接口、放行Swagger文档这几个核心环节。
项目依赖需要spring-boot-starter-web和jjwt。jjwt是目前Java生态最常用的JWT库,包含了api、impl、jackson三个模块。我在pom.xml里这样配置:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
这里有个细节,jjwt-api是编译期需要,impl和jackson是运行期才用到的,所以后两者scope设为runtime。如果不加这个区分,运行时会报ClassNotFoundException,排查起来还容易误判成依赖冲突。
4.2 封装JWT工具类
工具类主要做两件事:生成Token和解析Token。生成时把用户ID和用户名放进Payload,设置过期时间,用密钥签名。解析时把Token字符串解析回Claims对象,拿到里面的用户信息。
java复制@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private Long expire;
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expire * 1000);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secret.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
}
}
密钥的要求是至少32字节,对应HS256算法的最小长度限制。实际项目中不建议直接写在配置文件里,更稳妥的方式是通过环境变量或配置中心注入,避免密钥泄露导致JWT被伪造。Secret如果太短,Keys.hmacShaKeyFor会直接抛出弱密钥异常,这也是一个很常见的问题。
4.3 登录接口与Token签发
登录接口接收前端传来的用户名密码,校验通过后生成Token返回。这里只演示核心逻辑,真实项目里的密码校验一定是与数据库中的password_hash比对,而不是密码明文。
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public Result login(@RequestBody LoginRequest request) {
// 省略查库校验逻辑
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
return Result.success(token);
}
}
一个容易被忽视的细节:登录接口本身应该是公开的,如果整个项目都加上了鉴权拦截器,记得在配置里把登录接口放行。很多同学把接口写完,启动项目后一调登录接口就报401,通常就是这个原因。
4.4 拦截器实现鉴权与Swagger放行
核心鉴权逻辑放在拦截器里。拦截器从请求头取Authorization,解析Token,成功就把用户信息放进ThreadLocal,失败直接返回401。同时要放行登录接口和Swagger相关的路径。
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getMethod().equals("OPTIONS")) {
return true;
}
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
try {
Claims claims = jwtUtil.parseToken(auth.substring(7));
request.setAttribute("userId", claims.getSubject());
return true;
} catch (Exception e) {
response.setStatus(401);
return false;
}
}
response.setStatus(401);
return false;
}
}
Swagger放行是刚接触这个方案时最容易卡住的点。如果不放行,启动项目后访问swagger-ui.html页面能打开,但点接口调试时每个请求都带不上Token,全部401。我用的方式是让拦截器判断请求路径,命中Swagger的路径直接放行:
java复制public boolean preHandle(...) throws Exception {
String uri = request.getRequestURI();
if (uri.contains("/swagger") || uri.contains("/v3/api-docs") || uri.contains("/doc.html")) {
return true;
}
// 其余逻辑
}
在Spring Boot 3里,聚合接口文档的路径通常是/v3/api-docs,老的Swagger 2是/v2/api-docs,如果前后端用的界面是knife4j,访问路径是/doc.html。把这些路径都放行,开发体验会舒服很多。我在实际项目中还遇到过一种奇怪现象:Swagger文档页面能打开,但某些接口请求返回401,抓包发现这些请求没有经过拦截器,直接到了Controller,这种通常是因为路径匹配顺序或者Filter和Interceptor的优先级导致的,需要检查WebMvcConfigurer配置。
4.5 完整流程串联
配置好拦截器的注册后,整个流程是这样工作的:用户请求/api/auth/login,因为是公开路径直接放行;登录成功后拿到JWT;后续请求带Authorization: Bearer <token>访问受保护接口;拦截器校验Token,成功则设置用户信息,失败则401;用到用户ID的接口直接从请求属性里取。
这套流程里我踩过最大的坑是拦截器注册配置漏了静态资源配置,导致CSS、JS文件也被拦截。生产环境的访问用户会看到页面HTML正常但样式全部丢失。解决方式是注册拦截器时用excludePathPatterns把静态资源路径一并排除。
5. 常见问题与排查技巧实录
5.1 跨域时Cookie被浏览器拦截怎么办
这个问题的典型表现是:前后端联调环境一切正常,一旦改成线上域名,登录请求返回了Set-Cookie,但浏览器里看不到Cookie,后续请求也没有自动携带。
排查思路分三步。第一步看Cookie的Secure属性,线上用了HTTPS,但Set-Cookie如果没带Secure,浏览器会拒收;开发环境是HTTP,带了Secure也会拒收——这个属性要求协议必须匹配。第二步看SameSite,跨站请求默认SameSite=Lax会阻止Cookie携带,需要显式设置SameSite=None并配合Secure。第三步看Domain属性是否匹配前端域名,不匹配时浏览器直接忽略。这三步按顺序检查,通常能解决90%的Cookie问题。
如果前端项目本身和后端就是两个域名,我的建议是直接放弃Cookie方案,改用Token放Header。这不是技术上的妥协,而是顺应浏览器策略的选择——把凭证放在请求头里,跨域配置只需要处理Authorization头即可,不需要跟SameSite死磕。
5.2 JWT被在线解析和伪造的防范
把JWT粘贴到任何在线解析工具,Payload里的内容一览无遗,这只是第一步。更严重的是如果密钥太弱,攻击者可以通过暴力破解拿到密钥,然后自己伪造任意身份。我见过一个真实事故:某个项目在代码仓库里提交了JWT密钥文件,被内部扫描工具发现后拉了一堆生产Token,好在及时发现才没有造成实质损失。
防范措施有这几条。第一,不要在Payload里放敏感信息,这一点前面强调过;第二,密钥必须有足够的长度和随机性,不要用"123456"这类弱密钥;第三,密钥绝对不能提交到Git仓库,要用配置中心管理,并且定期轮换;第四,服务端应该校验aud(受众)和iss(签发者),防止一个环境的Token拿到另一个环境使用;第五,部署HTTPS,防止Token在传输过程中被截获。
5.3 Token过期了怎么续签不打断体验
JWT的exp一旦到达,请求就会401,用户被迫重新登录。如果Token有效期设得太短,用户体验会很糟糕;设得太长,安全风险又上升。业界通用的解法是引入Refresh Token双Token机制:Access Token有效期设30分钟到2小时,Refresh Token有效期设7天到30天。前端发现Access Token过期时,自动用Refresh Token换新的Access Token,用户无感知。
这条方案落地时有个细节要注意:每隔一段时间刷新一次Token,会产生大量新的Token,如果每次都重新签发,Redis等存储的键会越来越多。我习惯在签发新Token时复用同一个tokenId,这样刷新只是重置过期时间,不会产生大量冗余记录。前端在请求封装里要做一个队列机制,防止多个请求同时发现Token过期、同时发起刷新,造成重复刷新。我的做法是设置一个isRefreshing标志位,刷新期间其他请求先挂起等待,刷新完成后再重放。
5.4 开发环境常见故障速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 登录接口401 | 拦截器未放行登录路径 | 检查拦截器排除路径配置 |
| 带Token请求仍401 | Token过期或密钥不一致 | 用在线解析看exp,检查两环境密钥 |
| Swagger页面接口全部401 | Swagger路径未放行 | 放行/swagger、/v3/api-docs路径 |
| 浏览器Cookie被清空 | Secure或SameSite配置不当 | 按5.1的三步排查 |
| JWT解析报WeakKeyException | 密钥长度不足32字节 | 换足够长的密钥 |
| 前端刷新页面后登录态丢失 | Token只存在内存变量里 | 改为localStorage或增强Cookie存储 |
| 多实例部署后Session反复失效 | Session存内存未共享 | 引入Redis共享Session,或换Token方案 |
5.5 我踩过一次印象深刻的坑
有一年维护一个老项目,线上突发大量“用户需要反复登录”的工单。我登上服务器,发现应用进程正常运行,日志里也没有异常,但用户的登录态总是过一会儿就丢。排查到最后,发现是开发把Session超时时间误配成了2分钟。这个配置的值是个“看似无关紧要的小参数”,但线上用户量大,会话密集过期,体感就是“一直让登录”。
后来我总结了经验:凡是涉及会话超时、Token过期时间的配置,上线前必须有一个专门检查清单,单独核对测试环境的预期值和线上配置是否一致。这类问题最大的特点是“配置环境不一致”,代码逻辑完全正常,但行为在不同环境表现不同,极难定位。
最后说几句实在话
这四个概念不是“谁替代谁”的关系。Cookie是浏览器的存储机制,Session和服务端状态绑定,Token是凭证的统称,JWT是凭证的一种实现方式。选型时先问清楚自己的业务特点:是否需要主动踢人、是否跨域、是否多实例、是否有高并发要求,答案不同,方案完全不同。我在项目里见过把JWT用得漂亮的,也见过把Session用得极其稳的,关键是从业者心里要清楚每个方案背后的取舍。
最后再送大家一个小技巧:遇到搞不清的鉴权问题,先从“凭证在哪里生成、在哪里存储、在哪里验证”这三个环节逐一切分,问题基本就能定位到具体环节。把这条思路记在心里,比背下所有理论都管用。
