1. 这不是概念背诵题是每天都在写的登录逻辑你写过登录功能吗哪怕只是个学生作业、内部管理系统、或者自己搭的博客后台——只要用户要“记住我”“保持登录状态”你就绕不开Cookie、Session、Token这三个词。它们不是教科书里并列出现的抽象名词而是 HTTP 请求链条上真实存在的三类“身份凭证”各自承担着不同阶段、不同场景下的信任传递任务。我带过十几期后端开发训练营90% 的学员第一次调试登录失败根本原因不是代码写错了而是压根没搞清到底是谁在什么时候、用什么方式、把哪段数据塞进了哪条请求里比如你填完账号密码点登录浏览器发出去的第一个 POST 请求里有没有 Cookie服务端返回的响应头里Set-Cookie 是怎么写的前端拿到的 token 字符串是存在 localStorage 还是 sessionStorage下次请求时它又通过哪个字段AuthorizationX-Auth-Token还是直接拼在 URL 里重新交还给后端这些细节差一个字母整个认证流程就卡死。更现实的是你在用 Postman 测试接口时看到401 Unauthorized第一反应是查 token 是否过期但如果是500 Internal Server Error且日志里报There is no session with id xxx那问题可能出在 Redis 连接断了、Session 序列化失败、甚至 Tomcat 的 work 目录被误删——这和 JWT 签名密钥配错完全是两套排查路径。所以这篇不讲定义不列对比表格糊弄人只讲我在电商中台、政务 SSO、IoT 设备管理平台这三个真实项目里怎么选、怎么配、怎么调、怎么修。从 Chrome 开发者工具 Network 面板里真实抓到的请求头开始一帧一帧拆解数据流向。2. 核心设计思路为什么不能只用一种方案2.1 本质差异状态存储位置决定架构边界很多人以为 Cookie/Session/Token 是“替代关系”其实它们解决的是同一问题的不同切面如何在无状态的 HTTP 协议上维持有状态的用户会话。关键分歧点在于状态存哪儿谁来验证Cookie Session 组合状态存在服务端内存/Redis/数据库Cookie 只存一个无意义的 Session ID比如JSESSIONIDabc123。浏览器每次请求自动带上这个 ID服务端查表确认身份。好处是服务端完全可控——能随时踢掉某个用户、限制并发登录数、做敏感操作二次验证坏处是服务端必须维护状态横向扩展时 Session 同步成本高微服务架构下跨服务共享 Session 得靠 Redis 或 sticky session运维复杂度陡增。Token尤其是 JWT方案状态存在客户端localStorage/sessionStorageToken 本身是自包含的凭证比如eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...服务端只校验签名和有效期不查数据库。好处是彻底无状态API 网关、负载均衡器、后端服务都能独立验证适合大规模分布式系统坏处是 Token 一旦签发就无法主动作废除非加黑名单或缩短有效期且 Payload 里放太多信息会导致请求头膨胀HTTP Header 有 8KB 限制移动端尤其敏感。提示JWT 不是 Token 的唯一实现但它是最典型的“自包含 Token”。OAuth 2.0 的 Access Token 可以是 JWT也可以是 opaque string opaque token后者仍需服务端查库验证本质还是 Session 思路。2.2 场景驱动选型没有银弹只有权衡我经手的三个典型项目选型逻辑完全不同政务 SSO 平台对接 37 个委办局系统强制要求 Session 方案。原因很实际——审计合规要求所有登录行为可追溯、可实时终止。某局领导账号被盗安全中心必须 5 秒内踢掉其所有终端。JWT 无法做到这点而 Session 存 RedisDEL session:abc123一条命令搞定。我们甚至给每个 Session 加了user_id:dept_id:ip复合键方便按部门批量清理。电商中台日活 200 万订单/库存/营销服务拆成 12 个微服务核心链路用 JWT。下单时购物车服务、库存服务、支付网关都得校验用户身份如果每个服务都去查 Redis 获取 Session网络延迟叠加、Redis 成为单点瓶颈。改用 JWT 后网关统一验签透传user_id和role到下游服务各服务直接读取 Token 中的声明RT 从 120ms 降到 22ms。但客服后台这种低频、强管控场景仍用 Session方便坐席主管随时冻结账号。IoT 设备管理平台设备端用 C 语言 HTTP 库资源受限放弃 Cookie 和 JWT。设备端解析 JSON、验签 RSA 耗资源且固件升级后本地存储的 Token 无法刷新。改用轻量级 Token服务端生成 32 字节随机字符串token8a3f9b2e1c7d4a5f6b8c9d0e1f2a3b4c存入 Redis 并设置 7 天过期设备每次请求带Authorization: Bearer 8a3f9b2e...。服务端只做字符串比对零计算开销。注意所谓 “SPA 项目必须用 Token” 是伪命题。Vue/React 前端完全可以发登录请求服务端 Set-Cookie后续请求自动携带 Cookie配合 CORS 配置即可。是否用 Token取决于后端架构和安全策略而非前端框架。2.3 安全边界别让 Cookie 成为攻击入口很多开发者以为 “用了 HTTPS 就安全了”但 Cookie 的Secure、HttpOnly、SameSite属性才是防攻击的关键防线Secure确保 Cookie 只通过 HTTPS 传输防止中间人窃取。但注意开发环境用http://localhost时浏览器会拒绝设置 Secure Cookie导致登录失败。解决方案是开发时去掉Secure或用https://localhost需本地证书。HttpOnly禁止 JavaScript 访问 Cookie防御 XSS 攻击窃取 Session ID。但这也意味着前端无法读取 Cookie 做自定义逻辑比如显示登录用户名需服务端在响应体中额外返回用户信息。SameSite防 CSRF 的核心。SameSiteLax默认值允许 GET 请求携带 Cookie但阻止 POST 表单提交时发送SameSiteStrict更严格但可能导致用户从第三方链接跳转时登录态丢失SameSiteNone必须搭配Secure用于跨站嵌入场景如支付 iframe。我踩过的坑某次上线新版本前端用fetch发 POST 请求后端 Spring Security 默认SameSiteLax结果 Chrome 98 版本拒绝发送 Cookie所有接口 401。临时方案是后端显式配置SameSiteNone但必须同步开启Secure否则部署到 HTTP 环境直接失效。3. 实操细节拆解从请求头到数据库的一帧帧解析3.1 Cookie 的真实生命周期不只是浏览器里的小饼干Cookie 不是“存在浏览器里的字符串”而是一套由 RFC 6265 定义的协议机制。它的完整流转包含四个关键环节服务端下发响应头Set-Cookie: sessionIdabc123; Path/; HttpOnly; Secure; SameSiteLax; Max-Age3600Path/表示该 Cookie 在整个域名路径下有效若设为Path/api则/login接口无法读取。Max-Age3600是秒数优先级高于Expires时间戳设为 0 或负数表示立即删除。Domain属性常被滥用Domain.example.com允许a.example.com和b.example.com共享 Cookie但现代浏览器要求二级域名必须显式声明Domainexample.com不合法必须Domain.example.com且主域名不能设DomainDomainwww.example.com无效。浏览器存储与发送浏览器按域名、Path、Secure 等规则匹配自动将符合条件的 Cookie 加入请求头Cookie: sessionIdabc123; themedark。注意Cookie 是键值对集合多个 Cookie 用分号分隔但;在值中会被截断所以值里不能含分号。服务端解析Java Servlet 中request.getCookies()返回Cookie[]数组Node.js Express 中req.cookies.sessionId需cookie-parser中间件Python Flask 中request.cookies.get(sessionId)。过期与清理Max-Age到期浏览器自动删除用户手动清除浏览器 Cookie服务端下发Set-Cookie: sessionId; Max-Age0强制删除注意sessionId后必须有等号否则部分浏览器不识别。实操心得调试 Cookie 问题永远先看 Chrome DevTools → Application → Cookies这里显示的是浏览器当前存储的原始 Cookie比 Network 面板里看到的Cookie请求头更真实。曾遇到一次问题后端代码写了response.addCookie(new Cookie(token, ))但没设Max-Age0结果浏览器认为这是新 Cookie覆盖了旧值但没删除导致后续请求带着空字符串 token服务端解析失败。3.2 Session 的底层实现不只是内存里的 HashMapSession 看似简单但生产环境必须直面三个硬核问题存储介质选择内存Tomcat 默认重启即丢单机可用不适合集群Redis主流选择支持过期、原子操作、高可用但要注意序列化方式——Java 默认java.io.ObjectOutputStream生成的字节流升级 JDK 版本后可能反序列化失败。我们改用 Jackson 序列化成 JSON兼容性好且 Redis 里可直接GET session:abc123查看内容数据库可靠性高但 IO 延迟大仅用于审计日志等非实时场景。Session ID 生成安全Tomcat 默认用java.security.SecureRandom生成 128 位随机数足够安全。但某些老版本 Tomcat 8.5用Math.random()熵值不足。检查方法启动日志中搜索Session ID generator确认是否使用SecureRandom。粘性 SessionSticky Session陷阱Nginx 配置ip_hash或hash $cookie_sessionid实现粘性看似解决了 Session 共享问题实则埋雷某台机器宕机其上的 Session 全部丢失用户 IP 变化如移动网络切换基站Session 断连负载不均热门节点压力过大。我们曾因此在双 11 前紧急下线ip_hash改用 Redis spring-session-data-redis性能反而提升 15%因为避免了单点瓶颈。3.3 TokenJWT的构造与验签签名不是摆设JWT 由三部分组成Header.Payload.Signature用.连接。以常见登录返回为例HTTP/1.1 200 OK Content-Type: application/json { token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c }Header{alg:HS256,typ:JWT}→ 指定签名算法HS256 表示 HMAC-SHA256和类型Payload{sub:1234567890,name:John Doe,iat:1516239022}→subsubject是用户标识iatissued at是签发时间还可自定义role、permissions等SignatureHMACSHA256(base64UrlEncode(header) . base64UrlEncode(payload), secret)→ 用密钥对前两部分签名防止篡改。关键实操点密钥管理绝不能硬编码在代码里我们用 Spring Cloud Config Vault启动时从 Vault 拉取密钥内存中只存引用。曾因测试环境密钥泄露攻击者伪造管理员 Token刷单损失 200 万。Payload 大小控制一个 JWT Token 通常 500~1500 字节。若放入用户权限树JSON 数组含 50 个菜单项Token 膨胀到 4KB加上其他请求头超 HTTP Header 8KB 限制Nginx 直接返回400 Bad Request。解决方案Payload 只存user_id和role权限列表由前端首次登录后单独请求/api/user/permissions获取。刷新机制Refresh TokenAccess Token 短期如 30 分钟Refresh Token 长期如 7 天存于 HttpOnly Cookie。用户 Token 过期时前端用 Refresh Token 换新 Access Token。注意Refresh Token 必须绑定设备指纹User-Agent IP且每次使用后服务端需更新其值并吊销旧 Token否则重放攻击风险极高。4. 全流程实操从登录接口到登出的每一步代码与配置4.1 Spring Boot 项目实战Cookie Session 方案步骤 1基础依赖与配置!-- pom.xml -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency# application.yml spring: redis: host: 10.0.1.100 port: 6379 password: ${REDIS_PASSWORD:} # 从环境变量读取 session: store-type: redis timeout: 1800 # 30分钟 cookie: http-only: true secure: true # 生产环境必须 same-site: Lax步骤 2登录接口实现RestController public class AuthController { PostMapping(/login) public ResponseEntityMapString, Object login( RequestBody LoginRequest request, HttpServletResponse response) { // 1. 校验账号密码省略 DB 查询 User user userService.findByUsername(request.getUsername()); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { return ResponseEntity.status(401).build(); } // 2. 创建 Session存用户信息 HttpSession session request.getSession(true); session.setAttribute(user_id, user.getId()); session.setAttribute(username, user.getUsername()); session.setAttribute(role, user.getRole()); // 3. 设置 Cookie 属性Spring Session 自动处理但需确认配置生效 // 无需手动 setCookieSpring Session 会生成 JSESSIONID 并设置响应头 MapString, Object result new HashMap(); result.put(message, 登录成功); result.put(user, user); return ResponseEntity.ok(result); } }步骤 3受保护接口与登出RestController public class UserController { GetMapping(/api/user/profile) public ResponseEntityUser getProfile(HttpSession session) { // Spring Security 会自动注入 session此处演示原生用法 Long userId (Long) session.getAttribute(user_id); if (userId null) { return ResponseEntity.status(401).build(); // 未登录 } User user userService.findById(userId); return ResponseEntity.ok(user); } PostMapping(/logout) public ResponseEntityVoid logout(HttpSession session) { session.invalidate(); // 使 Session 失效Redis 中对应 key 被删除 return ResponseEntity.ok().build(); } }注意session.invalidate()不会立即删除 Redis 中的 key而是标记为过期Redis 在下次访问时清理。若需立即删除可注入RedisOperations手动执行delete(spring:session:sessions: sessionId)。4.2 Vue Spring Security JWT 方案步骤 1JWT 工具类JavaComponent public class JwtUtil { private static final String SECRET your-256-bit-secret-key-here; // 从 Vault 获取 private static final long EXPIRATION_TIME 30 * 60 * 1000; // 30分钟 public String generateToken(UserDetails userDetails) { Date now new Date(); Date expiryDate new Date(now.getTime() EXPIRATION_TIME); return Jwts.builder() .setSubject(userDetails.getUsername()) .claim(user_id, ((User) userDetails).getId()) .claim(role, ((User) userDetails).getRole()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } public String getUsernameFromToken(String token) { return Jwts.parser().setSigningKey(SECRET) .parseClaimsJws(token).getBody().getSubject(); } }步骤 2Vue 登录与请求拦截// api/auth.js export function login(data) { return axios.post(/api/auth/login, data) } // utils/request.js const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截自动添加 token service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error)) // 响应拦截token 过期自动刷新 service.interceptors.response.use(response response, error { if (error.response?.status 401 error.response?.data?.code TOKEN_EXPIRED) { // 调用刷新接口成功后重发原请求 return refreshToken().then(newToken { localStorage.setItem(access_token, newToken) error.config.headers.Authorization Bearer ${newToken} return service(error.config) }) } return Promise.reject(error) })步骤 3Spring Security 配置Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() // JWT 场景下禁用 CSRF因无 Cookie .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态 .and() .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/user/**).authenticated() .anyRequest().permitAll() ) .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } } // 自定义过滤器解析 JWT 并设置 SecurityContext public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null jwtUtil.validateToken(token)) { String username jwtUtil.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (bearerToken ! null bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }5. 常见问题与排查技巧实录线上故障的 12 个真实现场5.1 Cookie 相关故障速查表现象可能原因排查步骤解决方案登录后刷新页面提示未登录SameSite配置错误Chrome DevTools → Application → Cookies检查 Cookie 是否存在Network → Headers → Request Headers确认Cookie字段是否为空Spring Boot 配置server.servlet.session.cookie.same-siteLax若需跨站设为None并启用Secure登录成功但后续接口 401Path不匹配查看登录响应头Set-Cookie的Path值对比请求 URL 路径统一设为Path/或确保 API 路径前缀与 Cookie Path 一致如 Cookie Path/api则请求必须是/api/user移动端 App 登录失败HttpOnly阻止 JS 读取App 使用 WebViewJavaScript 无法获取 Cookie导致无法提取 token改用Authorization: Bearer方案或服务端在响应体中返回用户信息多标签页登录态不同步Session ID 覆盖两个标签页同时登录后一个覆盖前一个的 Cookie前端登录成功后主动刷新页面或服务端生成新 Session IDrequest.changeSessionId()5.2 Session 故障深度排查There is no session with id错误这不是代码 bug而是 Redis 连接问题。检查点Redis 是否存活redis-cli -h 10.0.1.100 pingSpring Session 配置的 Redis 数据库是否正确默认是db0若业务也用 db0可能被误删Session 过期时间是否过短spring.session.timeout1800单位是秒不是毫秒是否启用了EnableSpringHttpSession注解遗漏此注解Session 不走 Redis。Agent failed before reply: session file locked这是 PHP 的 Session 文件锁问题非 Java但原理相通。当一个请求长时间占用 Session如上传大文件其他请求会阻塞等待锁释放。解决方案登录成功后立即session_write_close()PHP或session.setAttribute()后调用session.flush()Java将耗时操作如发邮件、写日志放到异步线程避免阻塞 Session。5.3 Token 故障高频场景错误信息根本原因关键证据修复动作token exchange failed: token endpoint returned status 403 ForbiddenJWT 密钥不匹配用 jwt.io 解析 Token查看 Header 中kid字段对比服务端密钥配置确认kid对应的密钥是否加载检查 Vault 中密钥版本是否更新sign-in could not be completed token exchange failed: error sending requestToken 签发方域名不可达抓包看请求POST https://auth.example.com/oauth/token是否超时检查 DNS 解析、网络策略安全组/防火墙、TLS 证书是否过期unexpected status 502 Bad Gateway网关转发时 Token 被截断Nginx 日志中upstream sent too big header增加 Nginx 配置proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k;JWT signature does not match local signature时间偏差Skew服务器时间比 NTP 时间快/慢 60 秒sudo ntpdate -s time.nist.gov同步时间或 JWT 解析时设置clockSkewSeconds(60)5.4 安全漏洞实战复盘CNVD-2023-17316 的教训这个 Nacos 默认密钥漏洞本质是 JWT 的kid注入攻击漏洞原理Nacos 1.4.3 之前版本JWT Header 中kid字段未校验攻击者构造{alg:HS256,typ:JWT,kid:../../../etc/passwd}服务端用该路径读取密钥文件导致任意文件读取。我们的修复动作立即升级 Nacos 至 2.2.0所有自研 JWT 签名禁用kid字段或严格白名单校验只允许kidrsa256、kidaes128增加 WAF 规则拦截kid字段含../、/etc/等敏感路径的请求。最后分享一个小技巧线上环境永远用curl -v模拟请求比 Postman 更透明。比如调试登录curl -v -X POST http://api.example.com/login \ -H Content-Type: application/json \ -d {username:admin,password:123} \ -c cookies.txt # 保存 Cookie 到文件 curl -v -X GET http://api.example.com/user \ -b cookies.txt # 使用保存的 Cookie这样你能清晰看到每一行响应头包括Set-Cookie和Authorization比 GUI 工具少一层抽象。
