JWT会话设计:签发校验刷新与注销的实践

「登录状态到底存在哪」这个问题,在 PHP 圈子里有一个默认答案:session。而一旦站点要做成前后端分离、或者要上多台机器、或者要在手机上跑一个小程序,session 就开始不舒服了——多台机器之间需要共享 session 存储,移动端拿不到 Cookie,跨域的时候 Cookie 又被浏览器策略卡住。于是很多人转向 JWT。

JWT 确实是好东西,但它被误解得也很厉害。最典型的一句误解是「用了 JWT 就不需要存会话了」——这句话在技术上没错,在工程上会害人。本文用一个具体的多端带后台的站点做例子,把签发、校验、刷新、注销四件事讲清楚,特别是「注销」这个绕不过去的坎。

一、先弄清 JWT 到底解决了什么

JWT 是一段被签名的 JSON,结构是三段用点号连接:头部、载荷、签名。头部说明用了什么算法,载荷放业务声明,签名用来证明这段内容没被改过。关键在于:签名只能证明「没被篡改」,不能证明「还有效」。这是理解后面所有问题的起点。

它相对 session 的优势很明确:服务端不需要为每个登录用户存一条记录,验签是纯计算,天然适合水平扩展;跨语言跨端,一个 token 可以同时给网页、App、小程序用;可以把一些不敏感的信息(用户 ID、角色)直接放在载荷里,省一次查库。

它的劣势也很明确:服务端无法单方面作废一个已经发出去的 token,除非你引入一张「黑名单」或者「版本号」表——而一旦引入了,你其实又回到了有状态的 session,只是换了个形式。所以正确的心态是:JWT 把「状态存储」的成本从服务端转移到了一点点协议复杂度和一些额外的表上,它不是免费的。

顺便说清载荷的边界:JWT 的载荷只是 base64url 编码,不是加密。任何人拿到 token 都能解出里面的内容。所以放用户 ID、角色、过期时间可以,放手机号、邮箱、权限明细、任何涉及隐私的东西都不行。这条界限很多人踩过坑。

二、签发:两个 token,各管一段

只发一个长效 token 是最常见的错误做法。它一旦泄漏,攻击者可以一直用到过期,而你毫无办法。正确做法是成对签发:访问令牌(access token)短命,15 分钟到 1 小时;刷新令牌(refresh token)长命,7 天到 30 天,并且必须存在服务端,可以随时吊销。

两个 token 的分工是:access 用来频繁访问接口,泄漏了损失也有限;refresh 只用来换新的 access,使用频率低,所以可以放心地做严格的校验、限制来源、存入数据库。

签发时的实现要点:

$now = time();
$access = [
    'iss' => 'zz1984',
    'sub' => $uid,
    'iat' => $now,
    'exp' => $now + 900,
    'jti' => bin2hex(random_bytes(16)),
    'typ' => 'access',
];
$payload = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']))
         . '.' . b64url(json_encode($access));
$sig = hash_hmac('sha256', $payload, $secret, true);
$token = $payload . '.' . b64url($sig);

这里面有几个必须有的字段。exp 是过期时间,校验时必须检查,而且要用服务端时间,不要信客户端。iat 是签发时间,配合 exp 可以算出准确的有效期。jti 是这条 token 的唯一编号,它将来是黑名单和审计日志的主键——没有 jti,你连「吊销某一个 token」这件事都做不到,只能吊销全部。sub 放用户 ID,但不要放用户名,用户名改了 token 里的就是过期数据。

密钥的管理有一条硬规矩:密钥不能写在代码仓库里。放在环境变量或者独立的配置文件,权限设成 600,且与数据库密码分开存放。用 HS256 的话,密钥长度至少 32 字节随机值,不要用「zz1984_secret_2026」这种能猜到的字符串,哈希暴力破解 HS256 的速度非常快。如果多系统之间要互相验证 token,用 RS256(非对称),让验证方只拿到公钥,这样即使某个下游系统被攻破也发不出有效 token。

三、校验:五道关,一个都不能少

校验不是「解出 payload 看看 exp 有没有过」这么简单。我通常按顺序过五道关,任何一道不过就直接拒绝:

第一关,结构。必须是三段,用点分。少于三段或多于三段直接拒绝,这挡掉一部分畸形输入。

第二关,头部算法。取出头部的 alg,必须与你签发时用的算法完全一致,否则拒绝。这里有一个历史上非常有名的漏洞:早期一些库会信任 token 头里声明的算法,攻击者把 alg 改成 none,库就跳过验签直接通过;或者把 RS256 改成 HS256,然后用公钥当 HMAC 密钥去签名。现在主流库都修了这个问题,但自己手写校验的人经常忘。核心原则是:算法由服务端定,不由 token 定。

第三关,签名。用固定算法重算一遍,用 hash_equals 做常量时间比较——普通 == 会有时间侧信道,虽然实战中很难利用,但成本为零为什么不防。

第四关,时间。检查 exp 大于当前时间,iat 不大于当前时间(防未来时间戳),nbf 如果存在则必须已到。

第五关,业务。检查 typ 是不是 access(防止拿 refresh token 当 access 用——这是一个很常见的越权点),检查 sub 对应用户是否还存在、是否被禁用。这一关要查一次库或者缓存,很多人为了「无状态」跳过它,结果被封号的用户还能继续操作半天。

把这五关写成一个函数,所有需要鉴权的接口统一调用,不要每个接口自己解一遍。

四、存储:别放 localStorage

前端把 token 存哪,是一个安全性直接相关的决定。三种选择:localStorage、内存变量、HttpOnly Cookie。

localStorage 读写方便,但代价是任何 XSS 都能把它读走。而 XSS 在内容型站点上并不罕见——一个富文本编辑器没做过滤、一个第三方统计脚本被投毒,都能拿到。这是最流行的做法,也是最不该用的做法。

内存变量(存在 JS 闭包里)安全性高,XSS 拿不到持久存储,但刷新页面就丢,需要配合 refresh 恢复会话,而且多标签页之间不同步。

HttpOnly Cookie 是 JS 读不到的那种,XSS 偷不走。但它带来了 CSRF 问题——浏览器会自动带上 Cookie,所以必须配 SameSite=Lax(或 Strict),并对状态变更的接口做 CSRF 校验(双提交 token 或者校验 Origin 头)。这条路稍微麻烦一点,但它是目前最稳的组合。

我的建议是分场景:纯 Web 站点、同域,用 HttpOnly + Secure + SameSite=Lax 的 Cookie;前后端完全分离、跨域,把 access 放内存、refresh 放 HttpOnly Cookie(Cookie 只给刷新接口用,路径限定在 /api/auth/refresh),这样即使 access 被 XSS 拿走,损失也只有 15 分钟,攻击者也换不到新的。

五、刷新:并发刷新是最容易写崩的地方

刷新接口的逻辑看起来很简单:收到 refresh token,校验通过后签发一对新 token,并把旧的 refresh 作废。但真跑起来会立刻遇到一个问题:页面同时发起五个请求,五个都发现自己 401,于是同时去刷新。如果刷新接口是「旧 refresh 一次性使用」,那么第一个成功,后四个全部失败,用户被踢出登录。

两个层面的解法。前端层面,用一个 Promise 队列:第一个触发刷新的请求持有 promise,后面的都 await 同一个 promise,等它返回后统一用新 token 重试。这是标准做法,代码不长,但必须写在拦截器里,不能散落在各个请求里。

后端层面,做刷新宽限。旧的 refresh token 记录一个 used_at,如果在作废后的 30 秒内又收到同一个 token 的刷新请求,不要直接拒绝,而是返回上次签发的那一对新 token(把结果缓存起来)。这样既能防重放(30 秒之外一律拒绝),又能容忍并发。注意这个宽限窗口必须短,否则等于给攻击者留了重放空间。

还有一种更严格的策略叫「重用检测」:如果同一个 refresh token 在宽限之外被使用,说明它可能已经泄漏并被别人用过了,此时应该把这个用户的所有会话全部作废,强制重新登录。这是业界推荐的做法,代价是用户偶发被踢,收益是泄漏后能被及时发现。

六、注销:这一关绕不过去

前面说过,服务端没法直接让一个 JWT 失效。想实现「立即踢下线、改密码后所有设备失效」,有三种主流做法,按成本从低到高。

做法一,短期 + 黑名单。 access 只发 15 分钟,注销时把该 token 的 jti 写入 Redis 黑名单,过期时间设为 token 本身的剩余寿命。校验时多查一次 Redis。EXISTS 一次大概零点几毫秒,完全能接受。这样注销后最多 0 毫秒生效,代价是一张会自然过期的表。

做法二,用户版本号。 在用户表上加一个 token_version 字段,签发时写进载荷,校验时比对当前库里的值。改密码、踢下线、发现异常时把 token_version 加一,所有旧 token 立刻失效。这个方案的好处是一次操作让全部设备下线,且不需要维护黑名单列表;代价是每次校验要读一次用户记录(可以缓存在 Redis,加很短的 TTL)。

做法三,refresh token 表。 refresh 全部存库,注销就是删记录。access 本身靠短命自然过期。这个最简单也最常用,和做法一或二搭配使用效果最好。

实际项目里我通常用「做法二 + 做法三」:刷新令牌存库,注销删记录;用户级别用版本号,支持「退出所有设备」和「改密码强制下线」。至于单设备踢出,靠 refresh 表删单条记录加 jti 黑名单。

不管用哪种,有一条必须做到:注销接口必须是 POST,且必须验证当前 token。曾经见过一个站把注销做成 GET /logout?token=xxx,结果被一个图片标签的预加载把用户的会话全清了——因为链接被爬虫和预取机制反复请求。

七、几个实战中踩出来的细节

时钟偏差。多台机器的时间如果不同步,A 机器签发的 token 到 B 机器上可能被认为「还没生效」或者「已经过期」。所有机器开 NTP,校验时对 nbf 和 iat 留 30 到 60 秒的容忍窗口。exp 不要留容忍窗口,宁可让用户多刷一次。

token 长度。 载荷里塞太多声明会让 token 涨到几 KB,每个请求头都带这么多字节,带宽和解析成本都不小。经验是控制在 1KB 以内,超了说明你在 token 里放业务数据了,那应该走接口取。

日志里别打印 token。 很多人调试时把整个请求头打到日志里,token 就永久留存了。Authorization 头在日志里要打码,只留前 8 位。这也是一条合规要求,将来出问题能解释得清。

退出后按浏览器后退。 SPA 里常见的情况是:用户退出登录,token 清了,但按后退键回到上一页,页面是浏览器缓存,看起来像还登录着。解决方法是退出时把前端状态清空并强制 location.replace('/login'),同时给带用户数据的页面加 Cache-Control: no-store。

别自己写 JWT 库。上面那些 base64url 编码、常量时间比较、算法固定的细节,用成熟库一次解决(PHP 的 firebase/php-jwt、Go 的 golang-jwt)。自己手写最容易漏掉的就是算法固定和常量时间比较这两点,而这两点恰好是安全漏洞的高发区。

把这套走通之后,session 与 JWT 那点取舍其实就不纠结了:需要跨端、需要水平扩展、能接受多维护一张表,就用 JWT;同一个域内的传统站点、想省事,session 加共享存储依然是完全正确的选择,没必要为了时髦去换。

Last modification:October 26th, 2026 at 09:24 pm

Leave a Comment