每一个真正写过后台登录功能的开发,早晚都会在“用户登录之后怎么记住他”这个问题上纠结一遍。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:无状态的自解释通行证
JWT(JSON 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 + Cookie | JWT | Opaque Token |
|---|---|---|---|
| 用户状态存放位置 | 服务端(内存/Redis/DB) | 客户端(Token 内自包含) | 服务端(Redis/DB) |
| 服务端存储压力 | 随着在线用户数线性增长 | 几乎为零 | 随着在线用户数线性增长 |
| 主动撤销/踢人下线 | 容易,删服务端会话即可 | 困难,需黑名单或缩短过期时间 | 容易,删服务端映射即可 |
| 水平扩展友好度 | 需 Session 共享方案 | 天然友好 | 需共享存储 |
| 跨域/多端支持 | Cookie 受浏览器策略限制 | 非常好 | 非常好 |
| 安全侧重点 | XSS 窃取 Cookie、CSRF | 签名算法攻击、Token 泄露后无法撤销 | Token 被盗后可撤销,但要防存储滥用 |
| 典型承载方式 | Cookie | Authorization Header | Authorization Header |
这张表可以回答大多数“到底用哪种”的争论。JWT 的“无状态”是把双刃剑:撤销难的另一个意思就是无法主动控制风险。而 Opaque Token 看起来和 Session 一样要占存储,但它比 Session 更灵活,因为它不绑定 Cookie 和同源策略,可以非常干净地嵌入到纯 API 生态里。
2.2 拿业务场景套一下
如果是传统的服务端渲染 Web 网站,比如管理系统、门户网站,Session + Cookie 仍然是性价比最高的选择。用户不多、功能集中在同一个域名下,没有复杂的跨域需求,Session 提供了会话控制能力,登录状态异常也能随时处理,开发成本极低。硬换 JWT 只会增加没必要的复杂度。
如果是前后端分离的 SPA 项目加移动端 App,JWT 是大多数团队的默认选型,但我会强烈建议做成“短有效期的 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 双令牌方案,也是我更推荐的做法。登录成功后签发两个 Token:Access 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 里已经是常客,但现实生产代码里同样频繁出现。最常见的三种:
- alg=none 攻击:攻击者把 JWT 的 Header 里的
alg改成none,删掉签名部分,服务端如果没校验算法,验证逻辑就会被绕过。修复方法是验签前强制指定算法白名单,不允许none。 - 弱密钥爆破:HS256 是对称签名算法,密钥只有服务端知道。但如果密钥是
"secret"、"123456"这种弱口令,攻击者拿到一个合法 Token 后可以用 Hashcat 离线暴力破解出密钥,然后任意伪造 Token。修复方法是使用足够长的随机密钥,并定期轮换。 - RS256/HS256 算法混淆:服务端用 RSA 公钥验签,攻击者却用 HS256 对称算法,拿公开的公钥当 HMAC 密钥来签 Token,服务端如果不限制算法,就能伪造出合法 Token。修复方法和第一种一样,验签时必须白名单限定算法。
还有一个更深一点的攻击面是kid(Key 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 里被嗅探;SameSite=Lax或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/403 | SessionId 没携带或 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。每一个异常分支都该有明确的响应,该拒绝的拒绝,该提示的提示,不能把所有问题都丢给全局异常处理器。
最后再分享一个小技巧:不管选哪种方案,先把“过期、撤销、刷新”这三件事在文档里写清楚,再开始写代码。这三件事涉及的不只是登录接口本身,还有前端拦截器、网关配置、日志规范、运维监控,牵一发动全身。先在文档里把流程画出来,团队照着实现,比你边写边想省下至少一倍的时间。这些坑我都替你踩过了,按照这套思路去做,能少走不少弯路。