刚接手一个老项目时我差点被登录模块劝退。项目里Cookie、Session、Token、JWT四样东西全都在用接口层用JWT做鉴权老页面又依赖Session里的登录态还有个定时任务拿着Cookie去抓第三方数据。三个文件看起来都能“证明你是谁”但谁在什么场景下该信任谁代码里写得很含混。我花了两天时间梳理完把这些概念彻底厘清之后才真正理解那句烂大街的面试八股——“Cookie和Session有什么区别”背后其实是一整套Web会话体系的设计取舍。这篇文章我会用做项目时的视角把这四个概念放在一条时间线上讲为什么要有它们它们各自解决了什么又各自带来什么新坑最后落到真实项目里怎么组合使用。对刚入门的同学来说这是一份能直接“抄作业”的会话机制笔记对已经写过一段时间业务代码的人来说里面有几处坑和选型思路大概率能帮你少走弯路。1. 无状态HTTP遇上业务状态四种方案都从同一个痛点出发1.1 那些年我们挂掉的会话KFC式点餐的联网服务HTTP协议本身是彻底“无记忆”的。每一次请求都是独立的服务器处理完一个请求就把你是谁这件事忘得一干二净。老派的类比是KFC点餐你点完套餐服务员把餐递给你交易结束双方各走各路。下一次你再进去他不会记得你上次点了什么。早年纯静态网页时代这个特征没什么问题。但网站一旦做成了动态应用——需要登录、需要购物车、需要把服务端生成的页面拼出来——麻烦立刻就来了。你登录之后翻到第二个页面服务器已经不认识你了又跳回登录页。这是Web开发从静态走向应用之后遇到的第一座山。解决这个问题的思路大致分两派。一派是“服务器替你记”。用户第一次访问时服务器在它自己的库房里开一个格子记录下状态然后给用户发一个号码牌——也就是Session ID。你下次来出示号码牌服务员去库房查格子。这就是Session方案。另一派是“你随身带凭证”。服务器不存状态它给你一个盖了章、签了名的凭证比如JWT。你每次来都要出示这个凭证服务器只看凭证上的签名和有效期就能判断真假不用再去查库房。这就是Token/JWT思路。Cookie有点特殊它本身不是认证方案而是一种“让浏览器帮你保存数据”的机制是上面两个方案共同的载体。Session的号码牌靠Cookie传Token凭证在Web场景下也通常靠Cookie或本地存储带过去。理解了这个源头后面的一切都顺了。很多人在网上吵“Session好还是Token好”其实是在没有限定场景地讨论两个完全不同的体系自然争不出结果。1.2 两种思路各自的底气与软肋服务器记账和客户端凭证之争本质上是把“状态”放在哪里。服务器记账的最大优势是“可控”。用户退出登录直接销毁服务端记录就行改密码之后让该账号所有格子失效也容易。但代价也很明显服务器需要维护状态实例多了以后用户第一次请求落在A机器、第二次落在B机器B机器上没有他的记录会话就断了。这就是分布式场景下著名的“Session一致性问题”。客户端凭证的优势是“无状态”。服务器不用保存任何东西只做验签天然适合水平扩容API一开就能接APP、接小程序、接第三方。但代价是把风险外包给了客户端凭证一旦签发在有效期内服务器很难主动作废它。你说“让这个用户下线”——如果凭证没到期服务端做不到立竿见影地强制失效。理解了各自软肋你就不会拿着JWT去做需要强下线的系统也不会为了一个小内部管理系统去搭一套高可用的Session集群。选型永远是先看约束再看喜好。2. Cookie是张便签纸贴在哪、能贴多大、能不能贴中文2.1 从Set-Cookie说起浏览器和服务器的一次密约Cookie的正式出身可以追到RFC 6265。它的运转逻辑非常直白服务器在处理完请求后通过响应头里的Set-Cookie字段告诉浏览器“请把这段数据保存下来”浏览器下次发起请求时会自动在同名的请求头Cookie里带上这段数据。在Java里设置一个Cookie只要几行代码Cookie loginCookie new Cookie(lastLoginAccount, URLEncoder.encode(zhangsan, UTF-8)); loginCookie.setPath(/); loginCookie.setMaxAge(7 * 24 * 60 * 60); // 单位是秒7天 loginCookie.setHttpOnly(true); response.addCookie(loginCookie);前端拿Cookie绝大多数场景也是浏览器自动完成的。你手动操作通常只有两种情况一种是在DevTools里查看当前站点有哪些Cookie一种是调试接口时手动从浏览器复制Cookie下来用。在Chrome里查看Cookie的位置很固定按F12打开开发者工具切到Application应用程序面板左侧菜单里找到Storage存储下的Cookies点开就能看到当前域名下的所有Cookie包含它的Name、Value、Domain、Path、Expires、Size、HttpOnly、Secure、SameSite这些关键信息。调接口时如果某个页面需要登录态你完全可以从这里复制出Cookie粘贴到接口测试工具里。网上很多人问“某某网站的Cookie在哪看”本质都是这个路径只是不同浏览器叫法略有差别。2.2 Cookie的四个关键属性Domain、HttpOnly、Secure、SameSiteCookie能配置的选项不少但真正决定安全边界的我总结下来就四个。第一个是Domain和Path用来声明“这个便签贴在哪个范围内有效”。服务器不给Domain时Cookie默认只归当前主机名下。给成“.example.com”的话它就会跟着发往所有子域名。这个设计本身是为了方便共享登录态但它也是Session固定攻击和子域名Cookie覆盖问题的重要源头。我见过有人为了方便把Domain设成顶级域名结果主站接口和后台管理共享了同一套Cookie只要一个子站点被攻破整盘皆输。第二个是HttpOnly。HttpOnly普通用户看着神秘其实作用是这个Cookie只允许HTTP请求自动携带不允许页面的JavaScript脚本通过document.cookie读取。它能直接掐断XSS攻击窃取Cookie的路径。你只要记住一条铁律凡是用来维持登录凭证的Cookie必须加上HttpOnly。第三个是Secure要求这个Cookie只能走HTTPS传输。开发环境用HTTP的时候不能加这个属性否则Cookie压根儿发不出来但生产环境请务必带上。它防的是明文链路上的窃听——把登录凭证光着身子在街上跑被抓包工具顺手就扒走了。第四个是SameSite用来决定跨站请求要不要带上这个Cookie。把它设为Lax默认值或者Strict可以在很大程度上缓解CSRF跨站请求伪造攻击。原理不复杂CSRF攻击的关键一步是恶意网站诱导用户浏览器自动携带目标站点的Cookie发起请求。SameSite规定第三方请求不带Cookie攻击就断了腿。用表格总结一下属性作用常见误区Domain/Path限定作用范围为了省事把Domain设太宽导致跨站串号HttpOnly禁止脚本读取忘记设置XSS后Cookie被直接拿走Secure仅允许HTTPS携带开发环境没加没事生产忘了加就裸奔SameSite限制跨站携带旧项目没设置CSRF风险敞口长期存在2.3 中文、大小、失效时间容易被忽略的边界Cookie看起来是个Key-Value但它有几个非常容易踩的边界。第一字符编码。RFC 6265规定Cookie的值必须是可打印的ASCII字符中文、空格、分号、逗号这些都不能直接放进去。你直接把中文用户名丢进Cookie浏览器要么拒绝写入要么写入一个乱码值要么遇到分号被生生截断。正确姿势是先URL编码再写取出来时再解码也就是我上面代码里URLEncoder.encode那一步干的事。网上关于“cookie中文”的报错问题九成都是没做编码。第二容量。浏览器单域名下Cookie总数大概几十个单个Cookie大小4KB左右超出部分浏览器自己决定丢弃。你非要把用户购物车整个塞进Cookie早晚会碰到底部。现在存储大头都是走服务端Cookie只负责存标识符很少直接存业务数据。第三过期时间。MaxAge设为0表示立即删除负值表示会话Cookie——也就是浏览器关闭就没了。很多人习惯性把登录态Cookie的过期时间设成一个很大的天数导致用户在电脑上一直保持着登录状态一旦设备落到别人手里就出安全问题。建议所有涉及敏感凭证的Cookie过期时间都跟着业务需要来别拍脑袋写个“一年后”。2.4 Java后端操作Cookie的典型姿势Java Web里操作Cookie主要靠javax.servlet.http.Cookie新规范里是jakarta.servlet.http.Cookie。读Cookie时request.getCookies()会返回一个Cookie数组为null的情况很常见一定要做判空String token null; Cookie[] cookies request.getCookies(); if (cookies ! null) { for (Cookie cookie : cookies) { if (access_token.equals(cookie.getName())) { token URLDecoder.decode(cookie.getValue(), UTF-8); break; } } }写Cookie时要注意一点Cookie的Path属性决定它发往哪些路径。如果你登录后在“/user”路径下设置了Cookie而这个Cookie需要被“/order”下的接口读取那对不起浏览器不会带过去。最省心的做法是统一设成“/”让整站都能用然后再用Domain和SameSite控制边界。3. Session是把账本锁进服务器安全了一头麻烦却来了3.1 Session的一生创建、传递、存储、销毁Session方案在Web世界里统治了很多年。核心思想概括起来就是用户数据放在服务端客户端手里只有一把钥匙。流程拆开看是这样的用户第一次访问时服务端通过session机制检查请求里没有带上任何有效的Session ID于是新建一个Session对象生成一个唯一的ID把它写进响应通常通过Set-Cookie塞到浏览器里Java容器默认的Cookie名是JSESSIONID。浏览器后续请求带上这个ID服务端拿它去内存或缓存里找对应的Session对象找到就说明你已登录之后从Session里取用户ID、角色这些状态。Java里面代码写起来极其简单// 登录成功后 request.getSession().setAttribute(userId, user.getId()); // 后续接口读登录态 Object userId request.getSession().getAttribute(userId); if (userId null) { throw new UnauthorizedException(未登录); }Session的生命周期由三件事控制创建时机、空闲超时、主动失效。容器里对每个Session都有超时时间默认一般是30分钟。这个“30分钟”指的是空闲时间不是绝对存活时间——只要你一直有请求Session会一直续命。在Spring Boot里调超时时间很方便server: servlet: session: timeout: 30m手动踢人下线也很直接调用session.invalidate()直接把那个格子烧了。改完密码想让旧会话全部失效只需要遍历Session库逐条销毁或者给用户记录加一个状态版本号鉴权时发现版本不匹配就主动失效。这种“说踢就踢”的即时性是Session相对Token最大的优势。3.2 三十五亿次踩坑别再混淆Hibernate Session和Web Session这个坑我在新同事身上见了很多次也在社区里隔三差五看到人栽进去。问题是这样的项目里用了休眠Hibernate/JPA做持久层同时又用Servlet的HttpSession存登录态。某天代里出现了一句异常提示could not open hibernate session for transaction或者there is no session with id。看到“session”这个词新手第一反应是我的登录Session丢了吗我的Session失效了吗不是。Hibernate里的Session和Web会话管理的Session完全是两个物种中文都翻译成“会话”但上下文毫无关系。Hibernate的Session是它用来管理实体对象生命周期、维护数据库事务边界的“持久化上下文”本质上是数据库操作层面的一层缓存和工作单元。而HttpSession负责HTTP用户状态。报错说“no session”是在告诉你当前线程里没有一个开放的数据库持久化上下文根本扯不到用户登录态。很多JPA新手在Service层用Transactional没问题但在拦截器、定时任务或异步线程里直接调用实体getter时就会触发懒加载异常因为事务已经结束、持久化上下文已经关闭。这个问题跟Cookie和Token没半毛钱关系但它非常容易在排查会话问题时把人带偏。补充一个实用经验代码里看到“session”相关报错先分清是javax.servlet.http.HttpSession还是org.hibernate.Session或者是Spring的SqlSessionMyBatis它们只是同名同译工作场景南辕北辙。3.3 分布式服务下Session的三种自救方案单体服务时代Session方案很舒服——所有请求打在同一个进程里内存里拿状态跟吃糖一样简单。一旦服务从一台变成多台、从单进程变成微服务Session的仓库就散架了。最常见的症状是用户第一次请求落在A机器登录态存在A的内存里下一次请求被负载均衡转发到B机器B机器在自己内存里找不到那个Session直接判你未登录。这就是前面说的分布式Session问题。业内常见做法有三类。第一类是Session黏滞Session Stick让负载均衡器把同一个用户的请求始终转发到同一台机器。配置简单、改动最小但代价是把横向扩容锁死了一截——你没法随意增减机器粘住的机器一旦宕机这批用户全部会话丢失。第二类是Session复制让每台机器都把Session广播给其他机器。Tomcat自带这个能力DeltaManager但同步成本高机器一多广播风暴就来了生产中很少大规模用。第三类是集中式Session存储把Session数据抽出来放到Redis这种独立的共享存储里。Spring Session这个项目就是干这个的接入后httpSession的读写自动走Redis。这种方案是目前最主流的选择既保持了“服务端记账”的即时失效能力又解决了多实例共享问题。但集中式存储并不是免费的每次请求都多一次Redis读写延迟会比进程内Session高一点Redis本身也会成为可用性节点你还要给它做高可用。选型时心里得有这笔账。4. Token与JWT一份防伪签名不是一台加密保险柜4.1 Token是广义凭证JWT是其中一种具体格式先说清楚一个经常被混用的关系Token是一个泛称指代“客户端持有一串凭证发送请求时带上它服务端据此识别身份”。它的实现载体非常多——可以是随机字符串不透明令牌也叫Opaque Token也可以是结构化令牌。JWTJSON Web Token就是结构化令牌里面最流行的一种。用一句话区分Token是“我要验证你身份”这个需求JWT是“怎么把这个凭证造出来并且不容易造假”的解决方案之一。不透明Token和JWT最大的差别在于不透明Token本身没有业务含义服务端拿到Token后还要再查一次数据库或Redis才能知道它代表谁而JWT把用户标识、过期时间、角色这些信息直接写进了凭证文本里服务端验完签名后当场就能读出来。前者多一次查询但方便作废后者免了查询但不容易主动作废。在Java生态里JWT的实现库里最常用的是jjwtJava JWT官方维护积极、API简洁。下面的代码就是生产环境最常见的一段写法。4.2 JWT的三段式结构Header.Payload.Signature一个JWT字符串长得像这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U中间由两个点拆成三段分别是头部、载荷、签名。头部Header是一个JSON对象至少包含两件事签名算法alg和令牌类型typ。常见的是HS256和RS256前者是同一个密钥既负责签又负责验后者是私钥签名、公钥验签。载荷Payload是JWT的“正文”这里面可以放标准声明如iss签发者、sub主题、exp过期时间、iat签发时间、jti令牌ID也可以放自定义声明比如用户ID、角色。它只是一段Base64URL编码后的JSON不是加密——这是所有人最容易误解的一处。网上工具随便一贴就能解开它所以千万不能把密码、身份证号、手机号这类敏感信息直接放进Payload。对于JWT来说“防篡改”是签名的职责“防偷看”却在签名能力范围之外。签名Signature是对前两段拼接结果用密钥做哈希运算得到的。任何人对Header或Payload动了手脚验签必然失败。一段代码生成JWTjjwt 0.11.x版本Key secretKey Keys.hmacShaKeyFor(你的至少32字符长的神秘字符串.getBytes(StandardCharsets.UTF_8)); String jwt Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 30 * 60 * 1000)) // 30分钟过期 .signWith(secretKey, SignatureAlgorithm.HS256) .compact();解析和校验try { JwsClaims jws Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token); Claims body jws.getBody(); Long userId Long.valueOf(body.getSubject()); // 校验通过正常放行业务逻辑 } catch (JwtException e) { // 签名不对、过期、篡改统统走到这里 throw new UnauthorizedException(无效token); }整个流程概括起来很简单服务端签发时用密钥签名客户端请求时把JWT放在Authorization: Bearer 头里服务端验签通过后就信任里面的claims。4.3 代码实战之外JWT最容易踩的四个并发坑第一令牌分发链路。前后端分离项目里前端为了调用接口方便普遍习惯把JWT存在localStorage。这个做法最大的问题不是XSS本身而是XSS发生后攻击者能直接拿到token发起任意请求。把JWT放进HttpOnly Cookie能降低这个风险但又会带来CSRF问题。业界没有完美的两全其美方案通常的做法是短期access token放内存或者Cookie配合SameSite和CSP把头铁的风险压到最低。第二时钟偏差。JWT的exp过期时间依赖服务器系统时钟。如果你有两台服务器一台时间快了五分钟它校验“过期”就会跟另一台不一致。做法是在校验时允许一小段时钟偏差比如给过期时间加30秒的宽限窗口或者所有服务器统一用NTP校准时间。第三多端互踢。因为JWT无状态你想实现“在A设备登录后踢掉B设备”单靠JWT做不到。常规思路是把JWT的jti声明唯一ID存到Redis里每次鉴权时检查当前这个jti是否还在白名单里不在就拒绝。这样一来JWT的无状态特性其实已经被“打了个补丁”你实质上引入了存储这时候就要重新评估你还非它不可吗第四续签与刷新。这是JWT落地时最绕不开的话题单独拿出来说。4.4 续签与销毁无状态认证最头疼的两件事JWT聊到最后必然会撞上两座墙怎么续签怎么提前作废。续签的常规操作是“双token”方案登录成功后签发一个短期的access token比如30分钟过期和一个长期的refresh token比如7天过期。业务接口统一认access token它过期后前端拿着refresh token去请求一个刷新接口换取新的access token用户全程无感。refresh token要存到HttpOnly Cookie里并绑定设备信息和用户状态防止被偷去无限刷新。还有一种“滑动过期”策略每次请求时如果token剩余有效期低于某个阈值比如5分钟服务端顺手签发一个新token让有效期滚动续上。好处是用户不在线时token自然断掉坏处是每次续签都要重签一次且没法严格限制会话数量。如果要提前作废某个JWT没有捷径必须借助存储要么维护一个黑名单被注销的jti写入Redis并保留到过期时间要么维护一个白名单只有名单里活跃的jti才允许通过。白名单方案实质上跟Session差不多了但胜在你仍然保留了JWT的跨端携带能力适用于开放接口给第三方应用的场景。贴一张续签决策表方便你在设计结束方案时直接对号入座场景推荐方案理由无状态优先允许到期才失效单token 滑动过期实现最简接口无状态需要用户无感续命能接受Redisaccess refresh安全和体验兼顾要求踢人、改密码即下线JWT 白名单/黑名单放弃纯粹无状态换即时失效内部系统、不想引RedisSession或硬编码过期简单稳定比什么都强4.5 JWT的安全雷区不加密的Payload与别乱信的kidJWT用多了之后你会碰到几类经典安全问题。第一Payload泄漏。前面提过Payload只是Base64URL编码相当于明文。有些项目图省事把手机号、地址直接塞进Payload等于把隐私贴在额头。处理原则很简单JWT里只放能公开给客户端看的信息比如用户ID、角色真要拿敏感信息服务端再去查库。第二算法混淆攻击。攻击者修改Header里的alg字段从RS256改成HS256然后拿公钥当HMAC密钥来签名token。服务端如果盲目按照Header里的算法去验签就会用非对称公钥作为对称密钥校验出“有效”的假token。分析一下验签代码里签发的算法和校验的算法必须写死不允许跟着Header走。规范里那句“不要信任alg字段”说的就是这个。第三kid注入。JWT Header里可以带一个kidKey ID用于告诉服务端“我用的哪个密钥”。如果服务端用kid拼文件路径、拼查询语句去取密钥攻击者就可能传入“../../secret.key”“/dev/null”之类的值去诱导服务端读取预期外的内容或者传一个加密的密钥块做注入。正确的做法是密钥统一存在配置中心或密钥管理系统里key的唯一索引必须是内部严格校验过的合法值绝不直接用外部输入拼路径。第四默认密钥。这不是JWT特有的问题但风险极大。JWT的验证密钥如果是硬编码进项目里的同一把钥匙而且代码库里都有那等于没有签名。之前有一些开源中间件因为写死了默认密钥攻击者用公开的密钥一签一个准直接伪造管理员身份绕过认证。凡是可以配置密钥的地方上线前一定把默认值换掉并纳入配置中心管理。这个习惯能救你很多次。5. 放到真实项目里谁适合哪条路怎么组合不打架5.1 一张表看清四者的核心差异现在把四个概念拉平做个对比。这张表是我在做技术方案评审时习惯用的版本字段不多每一条都能直接指导决策维度CookieSessionToken不透明JWT状态存哪客户端服务端服务端查存储凭证自身验签即时失效靠过期时间可主动销毁可主动销毁需借助黑/白名单跨域支持麻烦SameSite限制麻烦方便天生方便水平扩展不受影响需共享存储需共享存储几乎无要求防篡改无无仅ID无仅ID有签名保护敏感数据不能存服务端安全服务端安全不能存明文可见性能开销几乎为零需查服务端需查服务端只验签无存储查询纯JWT时这表里最值得反复看的是第三行和第四行即时失效和水平扩展。这两项决定了你做什么系统该选什么方案。5.2 典型项目组合传统Web、SPA/APP、SSO做选型不是非此即彼现实中大多数项目是混着用的。我按自己经手过的项目类型把它们的组合方式总结一下。传统服务端渲染Web项目JSP、Thymeleaf、服务端MVC——主流组合是Cookie Session。登录成功往Session塞用户数据用户ID通过Cookie传递拦截器里查Session判权限。这套方案简单、直观、即时失效单体项目效率极高。缺点刚才说过分布式要引Spring Session扩容要考虑共享存储。前后端分离SPA 移动端APP——主流组合是Token/JWT。前端调接口带上Authorization头后端统一校验。这套方案对跨端友好APP、小程序、网页共用一套认证协议服务端天然无状态。要注意的点就是续签、作废和安全存储这些坑在前面章节都列了。多云、多组织、需要单点登录SSO的大型系统——主流组合是OAuth2 JWT或者CAS Token。这样设计的好处是第三方应用拿到access token后不需要知道统一认证中心的内部用户存储长什么样只要验签和拉用户信息就够。JWT在这里更多承担“跨信任域传递身份”的角色它在组织边界上的优势是纯Session方案很难替代的。另外提一句热词里很常见的“SPA项目开发之JWT验证码实现”在图形验证码场景中服务端生成验证码并签名到JWT里返回给前端用户提交表单时带着它来服务端验签后确认这个验证码是自己签发的且没过期。这种做法在无状态项目里很常见核心还是利用JWT“自包含、防篡改”的特性来传递一次性的挑战码。5.3 我的选型心得与避坑清单结合这几年踩过的坑我给出一条非常私人的选型心得新项目无特殊要求我默认选Token不透明或JWT但一定会给“主动失效”补一条后路。解释一下这条心得。无状态是未来的方向不管是上Kubernetes扩容还是接多个前端端无状态方案都省心。但纯粹JWT“无法主动踢人”这个缺陷在不少业务场景里会咬人——用户改密码了、员工离职了、异常登录要顶号了你都不能让旧token立即报废。所以我现在的习惯是JWT签发的token里带上jti同时在Redis里维护一份“可用会话”白名单。这样既保留了JWT自包含、跨端携带的优势又给了服务端即时干预的把手。最后给一份踩坑清单每一条都是真金白银换来的不要往JWT的Payload里放任何不能对外公开的敏感字段Base64不是加密。所有登录凭证Cookie一律HttpOnly Secure生产环境别去掉。JWT的验签算法写死在代码里别信任Header的alg字段。密钥统一从配置中心读取绝对不要写死默认值并提交到代码仓库。会话超时的“30分钟”指的是空闲期不是绝对寿命调配置前先想清楚你要哪种语义。看到“no session”先分清楚是HttpSessionWeb会话、Hibernate Session持久化上下文、还是MyBatis的SqlSession方向错了排查半小时起步。JWT续签时refresh token的过期时间不要设成永久而且要绑定设备信息防止被偷用。我的习惯是把这套规则沉淀成团队内部的开发规范新项目初始化的时候直接套用能省掉大量“为什么会话失效”“为什么token验不过”的重复排障。这四个概念说到底都是工具工具没有绝对高下只有适不适合你的业务形态——把这个想通了面试和实战都不再是背题。
