ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JWT、Session与Opaque Token:登录认证方案选型与安全实践指南

JWT、Session与Opaque Token:登录认证方案选型与安全实践指南 每一个真正写过后台登录功能的开发早晚都会在“用户登录之后怎么记住他”这个问题上纠结一遍。JWT、Session Cookie、Opaque Token不透明令牌这三样东西在面试里被翻来覆去地问真到了做技术选型的时候又发现它们的边界其实很容易混淆。我把自己在几个项目里从 Session 切到 JWT又从 JWT 硬生生引回 Opaque Token 的过程整理了一遍把原理、选型、落地代码、安全漏洞和线上排查经验一次性说透。这篇文章适合后端开发、前后端联调的同学也适合刚接触身份认证的新手核心解决三件事三种登录方式各自是什么、不同业务场景怎么选、上线之后遇到问题怎么快速定位。1. 三种登录方式的核心原理与设计动机很多人一上来就背八股文“JWT 是无状态的Session 是有状态的Opaque Token 就是随机字符串。”背得没错但你要真问一句“为什么设计成这样”不少人就卡住了。先不急着对比把三种方式的来龙去脉讲清楚后面所有选型和踩坑都好理解了。1.1 Session Cookie服务端记账本模式Session Cookie 是 Web 时代最传统的登录方案。用户提交用户名密码服务端验证通过后在服务端的内存里或者 Redis 里创建一个 Session 记录里面存着用户 ID、登录时间、过期时间这些数据。同时生成一个唯一的 SessionId通过响应头里的Set-Cookie返回给浏览器。浏览器以后每次请求都会自动带上这个 Cookie服务端拿 SessionId 查一下自己的存储如果匹配就认为请求来自已登录用户。可以把这个过程理解成去健身房办卡健身房服务端有一个会员档案柜你进门时报手机号SessionId前台查一下档案确认你是会员就放行。档案存在健身房你可以随时换电话档案一查就知道是你。这种模式最大的优点就是“状态可控制”。用户被踢下线、改权限、封号服务端直接改一下存储对应记录就生效因为所有信任信息都在自己手里。缺点也来自这里服务端必须保存会话状态一旦用户量上来内存和存储开销是线性增长的。更麻烦的是水平扩展时如果负载均衡把请求分发到了另一台服务器那台机器上没有这个用户的 Session用户就掉线了。所以落地时几乎都要用 Redis 之类的集中存储来做 Session 共享架构复杂度一下子就上来了。1.2 JWT无状态的自解释通行证JWTJSON Web Token就是冲“无状态”这个目标去的。登录成功后服务端不保存任何东西而是生成一段格式固定的 Token 返回给客户端。JWT 由三部分组成用点号分隔Header.Payload.Signature。Header 里是签名算法和 Token 类型Payload 里是业务数据比如用户 ID、角色、过期时间Signature 是服务端用密钥对前两部分签名之后的结果。客户端下次请求时把 Token 放在请求头里通常是Authorization: Bearer token服务端验签通过后直接解出 Payload 就能拿到用户身份不需要查任何存储。拿演唱会门票类比门票上印着座位号、场次、持有人信息入口安检员拿设备验证门票上的防伪水印签名水印对了就放行。验票员不需要联网查数据库因为信任信息全写在这张票上了。JWT 最大的好处是无状态、天然支持跨域和分布式。服务端不用存 Session水平扩展毫无压力移动端、SPA、第三方 API 都能简单接入。但它有两个天生的短板。第一Token 一旦签发在过期之前都没法主动作废除非服务端维护一套黑名单可那样又违背了“无状态”的设计初衷。第二Payload 只是 Base64Url 编码不是加密任何人把 Token 解构一下就能看到里面的内容所以敏感信息绝对不能放进去。我在项目里见过有人把手机号、身份证号直接塞进 Payload等于把这信息明文写在客户端这是很危险的做法。1.3 Opaque Token不透明的服务端钥匙串Opaque Token 翻译过来是不透明令牌也叫引用令牌。它本身就是一串随机字符串可能是 UUID也可能是安全随机数生成的。服务端把 Token 和用户信息的映射关系存在自己的存储里内存、Redis、数据库都行。验证的时候客户端拿着 Token 来服务端查一下存储找到映射关系就认为用户已登录。Opaque Token 和 JWT 最大的区别是“不透明”——Token 里不携带任何业务信息你把它解构一百遍也什么都看不出来它更像一把储物柜钥匙具体存了什么只有服务端知道。认真想一下Opaque Token 和 Session Cookie 本质上是同一类东西都是“服务端保存状态 客户端保存引用凭证”。区别在于 Opaque Token 天生不依赖 Cookie 机制可以出现在请求头里也可以被移动端 App 放在本地存储里不受浏览器同源策略限制所以它在 API 场景和 OAuth 2.0 授权体系里非常常见。RFC 7662 定义了 introspection 端点资源服务器可以拿 Opaque Token 去授权服务器查询它到底代表什么用户、在什么权限范围内这套机制在微服务架构里被大量使用。把三种方式放在一起看其实就是在回答两个问题状态存在哪里令牌里装什么。Session 存在服务端Cookie 只是个 SessionId 的运输工具JWT 把用户信息直接塞进客户端Opaque Token 在客户端放一个随机串真正的状态留在服务端。理解了这两个问题后面的选型就清晰了。2. 技术选型不同业务场景该选谁技术选型本质上是取舍没有什么“JWT 一定比 Session 好”的绝对结论。我见过不少团队看网上都说 JWT 先进就盲目往传统 Web 项目里硬塞结果客户端存的是 Cookie 里的 JWT服务端为了校验还查数据库两边不讨好。选型之前先列清楚自己的业务到底在意什么。2.1 四个维度的硬核对比维度Session CookieJWTOpaque Token用户状态存放位置服务端内存/Redis/DB客户端Token 内自包含服务端Redis/DB服务端存储压力随着在线用户数线性增长几乎为零随着在线用户数线性增长主动撤销/踢人下线容易删服务端会话即可困难需黑名单或缩短过期时间容易删服务端映射即可水平扩展友好度需 Session 共享方案天然友好需共享存储跨域/多端支持Cookie 受浏览器策略限制非常好非常好安全侧重点XSS 窃取 Cookie、CSRF签名算法攻击、Token 泄露后无法撤销Token 被盗后可撤销但要防存储滥用典型承载方式CookieAuthorization HeaderAuthorization Header这张表可以回答大多数“到底用哪种”的争论。JWT 的“无状态”是把双刃剑撤销难的另一个意思就是无法主动控制风险。而 Opaque Token 看起来和 Session 一样要占存储但它比 Session 更灵活因为它不绑定 Cookie 和同源策略可以非常干净地嵌入到纯 API 生态里。2.2 拿业务场景套一下如果是传统的服务端渲染 Web 网站比如管理系统、门户网站Session Cookie 仍然是性价比最高的选择。用户不多、功能集中在同一个域名下没有复杂的跨域需求Session 提供了会话控制能力登录状态异常也能随时处理开发成本极低。硬换 JWT 只会增加没必要的复杂度。如果是前后端分离的 SPA 项目加移动端 AppJWT 是大多数团队的默认选型但我会强烈建议做成“短有效期的 Access Token 长有效期的 Refresh Token”双 Token 结构。这样兼顾了无状态扩展和风险控制用户不活跃一段时间后 Access Token 自然过期Refresh Token 也能在服务端控制。如果是开放 API 给第三方应用调用或者微服务之间需要传递身份信息Opaque Token 配合 OAuth 2.0 会舒服很多。授权服务器统一签发和吊销 Token资源服务器只需要调用 introspection 接口验证不需要和业务数据存储打交道。对于合规要求高的场景比如需要精确记录“谁在什么时间登录、什么时候注销”Opaque Token 的存储模型也更适合做审计。还要补充一点技术选型不是非此即彼。我实际负责过一个中台项目网关层给外部系统发的是 Opaque Token方便统一吊销和审计内部服务间调用反而用 JWT因为高频且不涉及用户敏感状态。混用的时候做好分层身份认证网关负责发牌验牌下游服务只关心“当前请求是谁”这种组合在大型系统里非常常见。3. JWT 落地实操从登录签发到自动续签不少同学看教程写的 JWT 示例只有一个createToken方法真到项目里才发现坑一个接一个Token 续签怎么做Swagger 文档接口怎么放行用户改密码之后旧 Token 还能不能用这一节我把一套相对完整的落地流程拆开来讲。3.1 签发与验签的核心逻辑先看最基础的签发和验签。以 Spring Boot 生态常见的 Java 写法为例核心逻辑长这样// 生成密钥HS256 要求至少 256 位32字节 SecretKey key Keys.hmacShaKeyFor(your-strong-secret-key-please-change-me-123456.getBytes()); // 登录成功后签发 Token String token Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, user.getRole()) .setIssuer(your-system) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 15 * 60 * 1000)) .signWith(key, SignatureAlgorithm.HS256) .compact();验签的代码并不复杂但要注意解析过程中的异常处理try { Claims claims Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); // 从 claims 里取 sub 和 role } catch (ExpiredJwtException e) { // Token 过期提示客户端走刷新流程 } catch (SignatureException e) { // 签名不合法直接拒绝 } catch (MalformedJwtException e) { // Token 格式不对 }两个细节很容易踩坑。第一HS256 的密钥强度直接决定安全水位网上教程里那种secret当密钥的写法用 GPU 暴力跑分分钟就能破解公司内部工具可以这么玩生产环境绝对不行。第二ExpiredJwtException和SignatureException这两个异常一定要分开处理——过期是业务事件可能需要走续签签名异常是安全事件直接拒绝请求并打日志告警不能一锅端地返回“未授权”。从拿到 Token 到真正确定用户身份中间还有一个容易忽略的环节不要拿 JWT 里解出来的用户 ID 直接信任到底。JWT 里存的是“签发时刻”的用户状态如果用户在这期间被改了角色、被禁言甚至删号JWT 里还是老样子。我的建议是高安全要求的场景验证 JWT 之后拿subject去用户服务查一次实时信息对性能极度敏感的内部服务可以信任 JWT 的 claim但前提是要接受一个时间窗口内的状态不一致。3.2 Token 续签滑动过期与 Refresh Token 双令牌热搜词里“jwt实现token续签”被搜得很多可见这是大家都绕不过去的实操难题。JWT 一旦到期就要重新登录体验很差所以要想办法续签。主流方案有两种。第一种是滑动过期每次请求都检查 Token 的剩余有效时间如果少于某个阈值比如 10 分钟就在响应头里重新签发一个新的 Token。这种方案实现很简单用户只要一直在用就不会掉线但代价是几乎每次请求都可能触发重签而且 Token 理论上永远有效只要活跃度够高不符合高风险场景的安全要求。第二种是 Refresh Token 双令牌方案也是我更推荐的做法。登录成功后签发两个 TokenAccess Token 有效期短15 分钟到 2 个小时Refresh Token 有效期长7 天到 30 天。客户端在 Access Token 过期后拿 Refresh Token 调用刷新接口服务端验证 Refresh Token 还有效就签发一组新的 Token。Refresh Token 也可以做轮换每次刷新都返回一个新的 Refresh Token并把旧的作废一旦发现旧 Refresh Token 被重复使用说明可能泄露了直接注销整条登录链路。刷新接口本身也要防重放。Refresh Token 泄露的风险比 Access Token 更大因为它有效期长所以明文存储是不可接受的。我在项目里一般把 Refresh Token 的 Hash 值存到 Redis并绑定设备信息、IP 段刷新时做二次校验。这样即使 Token 被偷走攻击者换个设备也没法用。3.3 接口白名单与 Swagger 放行有实际开发经验的同学都知道登录功能做完后第一件事是联调接口文档。Spring Boot 项目里 Swagger 相关路径都需要从 JWT 拦截器里放行否则还没看到接口文档请求就被拦截器拦了。“springboot jwt 放开swagger”这个搜索词几乎每个接入 JWT 的团队都会查一次。常见的放行路径分为几类/swagger-ui.html、/swagger-ui/**、/v3/api-docs/**、/webjars/**以及你自己的登录接口、验证码接口。在拦截器或者 Spring Security 配置里把这些加入白名单即可。但要注意放行接口文档不等于把文档接口暴露到公网生产环境一定要关掉 Swagger或者至少加上访问权限限制。我见过有团队图省事所有环境统一放行结果开发环境接口文档被爬虫抓了个遍连数据库连接串都泄露了。另外Spring Boot 项目里引入 JWT 后拦截器配置顺序也很关键。如果拦截器注册顺序不对静态资源或者 CORS 预检请求会被先拦截前端调试时总会莫名其妙出现跨域失败。我的经验是 CORS 过滤器放在最前面JWT 拦截器紧跟其后并且对OPTIONS请求直接放行因为浏览器跨域预检是不带业务凭证的。如果你用的是 .NET Core 或者 Node.js原理是一样的中间件执行顺序决定了认证过滤器能否正确跳过白名单路径不管什么技术栈核心思路都是“先白名单再认证”。4. 安全风险三个方案都躲不过的坑登录认证是系统安全的第一道门这里出漏洞往往直接等于用户数据裸奔。我整理了几个高频的高危漏洞和修复建议有 WebGoat 上赛题原型改过来的真实案例也有生产环境里踩过的坑。4.1 JWT 的高危攻击面与修复建议JWT 攻击在 CTF 里已经是常客但现实生产代码里同样频繁出现。最常见的三种algnone 攻击攻击者把 JWT 的 Header 里的alg改成none删掉签名部分服务端如果没校验算法验证逻辑就会被绕过。修复方法是验签前强制指定算法白名单不允许none。弱密钥爆破HS256 是对称签名算法密钥只有服务端知道。但如果密钥是secret、123456这种弱口令攻击者拿到一个合法 Token 后可以用 Hashcat 离线暴力破解出密钥然后任意伪造 Token。修复方法是使用足够长的随机密钥并定期轮换。RS256/HS256 算法混淆服务端用 RSA 公钥验签攻击者却用 HS256 对称算法拿公开的公钥当 HMAC 密钥来签 Token服务端如果不限制算法就能伪造出合法 Token。修复方法和第一种一样验签时必须白名单限定算法。还有一个更深一点的攻击面是kidKey ID头注入。JWT Header 里的kid字段如果直接被服务端拿去拼文件路径读密钥文件攻击者把kid指向/dev/null或者任意可控文件就能控制验签内容。这类问题排查起来非常隐蔽建议对kid做严格的值白名单校验密钥存储路径不要拼接客户端传入的任何东西。4.2 Session 固定攻击与 Cookie 安全属性Session Cookie 方案的高危问题里Session 固定攻击是经典中的经典。攻击者先自己获取一个未认证的 SessionId诱导受害者使用这个 SessionId 登录。如果服务端在登录成功后不重置 SessionId攻击者就能拿着这个 SessionId 冒充受害者。CTF 里很常见的 session 固定攻击就是这个套路。修复方式非常简单登录成功后必须调用request.changeSessionId()Java或者session_regenerate_id()PHP重新生成 SessionId同时把旧的 Session 数据迁移过来。这是写登录功能时的基本要求但在真实项目里没有做 Session 重置的代码仍然不少。Cookie 本身的安全属性同样容易被忽略。正确的设置至少应该包含这几项HttpOnly表示 JavaScript 读不到 Cookie可以防 XSS 攻击者偷走会话凭证Secure表示只允许 HTTPS 下传输防止明文 HTTP 里被嗅探SameSiteLax或Strict可以缓解 CSRF 攻击。相信我光是这三项就已经拦住了大量最常见的会话劫持路径。4.3 Opaque Token 与通用安全实践Opaque Token 看起来就是随机字符串很多人觉得“不透明”就安全了其实不然。它的安全性高度依赖 Token 的生成方式。如果直接用 UUID、自增 ID 或者时间戳做 Token攻击者要么能猜出来要么能枚举出来等于把身份凭证直接写在门牌号上。正确的做法是使用密码学安全的随机数生成器生成至少 32 字节的随机值再配合 Base64Url 编码输出。Opaque Token 的存储端同样要防泄露。不要把 Token 明文直接扔数据库存 Hash 值类似对密码一样做单向散列Redis 里设置过期时间键用户注销时直接删除映射。提供 introspection 接口的时候要限制调用方身份且必须做限流因为这类接口天然是暴力破解的靶点。所有方案通用的安全基线还有几条全链路必须走 HTTPS凭证不上日志日志里即使打 Token 也要脱敏只保留前几位。我见过一次线上事故排查问题时把用户的完整 JWT 打到日志里后来日志平台被拖库所有用户的登录凭证全部泄露这种教训一次就够了。5. 线上问题排查实录与经验汇总认证系统上线后白天黑夜都会遇到各种奇奇怪怪的问题。这里挑几个我实际遇到过、也在搜索热词里反复出现的典型问题按“现象-原因-解法”整理成速查表后面再补充一些技术文档里不会写的经验。5.1 常见问题快速排查表典型现象可能的原因排查思路与解法接口报 “session token is expired”Access Token 已到期但客户端没触发刷新检查前端拦截器刷新逻辑确认刷新接口是否正常返回新 Token登录后调接口仍 401/403SessionId 没携带或 Cookie 作用域不对检查 Cookie 的 Domain、Path、过期时间浏览器 DevTools 里看请求头服务重启后用户全部掉线Session 直接存在本地内存换成 Redis 存储或者改 JWT 无状态方案JWT 能解析但用户信息不对Payload 里用的是旧 claim或用户服务缓存了旧数据以数据库/用户服务实时数据为准核查签发 token 时的字段映射同一账号多个端登录互相顶掉Token 或 Session 存储里没做多端区分把设备信息写入会话记录做“互踢”或“允许并存”本地 IDE 调试时 “local session manager 占用 CPU 过高”本地调试工具/编译器服务异常不是业务系统问题检查调试器、语言服务进程重启本地环境别往认证上查刷新接口被刷导致存储压力大Refresh Token 没有做轮换和过期检查实现 Refresh Token 轮换旧 Token 作废成功后立即删除渗透测试发现“exploit completed, but no session was created”漏洞利用成功但最终没获取到目标会话往往是凭证被加固拦住复现攻击路径核查认证逻辑里的异常处理是否全部拒绝而非放行定时任务/内网系统频繁弹出登录服务端鉴权方式不匹配用了 Session 但请求方不携带 Cookie内部服务调用切换为 Header 里传 Opaque Token 或 JWT接口响应慢且数据库连接数偏高Session/Opaque Token 大量走数据库查询换成 Redis 缓存必要时引入 JWT 无状态化每次排查认证问题我的第一个建议是先把“凭证在哪个环节失效”定出来。要么客户端没带上凭证要么服务端验签/查存储失败要么凭证本身过期了。把这三段拆开问题基本就能定位到具体代码不会在整条链路里瞎猜。5.2 越早知道越好的几条实操心得第一认证方案要提前想清楚“踢人下线”的需求。我做的第一个前后端分离项目上线第二周产品经理就提了“后台能强制让某个用户重新登录”的需求。因为那时候用的 JWT踢人根本无法实现最后只能在用户表上加状态字段强行走一次实时校验绕了个大圈。现在做新项目我会直接在方案评审阶段就把“会话撤销”这个需求问清楚。第二日志里永远别打完整凭证。这是被数据泄露事故教育过之后长出的记性。无论 SessionId、JWT 还是 Opaque Token日志里只保留前 6 位加星号或者直接打 Hash排查问题时靠关联 ID 去查不要图方便把整个 Token 打出来。很多数据泄露事件不是核心数据库被攻破而是日志系统先被脱库了。第三JWT 的 Payload 别存任何敏感业务数据。Base64Url 编码等于明文我见过有团队把用户手机号、身份证号写进 Token 里前端拿到后直接展示。身份证、手机号、住址这种信息一旦进了客户端就等于公开了这个底线不能碰。第四密钥管理别硬编码在代码仓库里。开发环境无所谓但生产环境的签名密钥一定要走配置中心、环境变量或者 KMS并且要有轮换机制。我见过密钥泄露之后为了重启所有人登录态只能在代码里把所有用户 token 都加黑名单那次改动影响了几万在线用户。密钥管好能省很多这种事情。第五认证逻辑的异常分支要在测试阶段就覆盖全。ExpiredJwtException、SignatureException、MalformedJwtException这些异常很多团队联调时根本碰不到上线后被人拿篡改过的 Token 一打才发现居然返回了 500 而不是 401。每一个异常分支都该有明确的响应该拒绝的拒绝该提示的提示不能把所有问题都丢给全局异常处理器。最后再分享一个小技巧不管选哪种方案先把“过期、撤销、刷新”这三件事在文档里写清楚再开始写代码。这三件事涉及的不只是登录接口本身还有前端拦截器、网关配置、日志规范、运维监控牵一发动全身。先在文档里把流程画出来团队照着实现比你边写边想省下至少一倍的时间。这些坑我都替你踩过了按照这套思路去做能少走不少弯路。
返回列表