☰
拿到一个 JWT 先解开看什么:atob 报错、exp 差了一千倍、alg 写 none,排查 token 的六个点
2026/10/2 6:23:28 网站建设 项目流程

排查登录问题时,经常要看一眼 token 里到底装了什么:用户 id 对不对、角色是什么、什么时候过期。JWT 的前两段就是 base64url 编码的 JSON,解开很容易,但「解开」这一步本身就有几个坑,解开之后怎么判断过期、怎么校验,坑更多。

下面六个点,都配了能直接跑的代码和实际输出(Node 25.8 实测,浏览器里行为一致)。示例 token 是我为这篇文章现签的,密钥是假的。

1. 直接 atob 会报错,或者解出乱码

JWT 用的是 base64url,不是标准 base64:+换成-,/换成_,末尾的=去掉。再加上 payload 里经常有中文昵称,于是:

constseg=token.split('.')[1];atob(seg);// InvalidCharacterError: Invalid character

这个 token 的 payload 里有一个昵称「小张🐱」,编码后恰好出现了-,atob直接抛异常。没出现-_的 token 能解开,所以这个 bug 往往是「大部分用户正常,个别用户解析失败」,很难查。

把字符换回来、补齐=之后,atob不报错了,但中文变成了乱码:

functiondecodeSegmentNaive(seg){constb64=seg.replace(/-/g,'+').replace(/_/g,'/');returnatob(b64.padEnd(b64.length+(4-b64.length%4)%4,'='));}// {"sub":"10000","name":"å¼ ä¸","nick":"å°å¼ ð±",...}

原因是atob返回的是「一个字符一个字节」的二进制串,而 JSON 是 UTF-8 编码的,中文要三个字节、emoji 要四个字节。中间少了一步 UTF-8 解码:

functiondecodeSegment(seg){constb64=seg.replace(/-/g,'+').replace(/_/g,'/');constbin=atob(b64.padEnd(b64.length+(4-b64.length%4)%4,'='));constbytes=Uint8Array.from(bin,c=>c.charCodeAt(0));returnJSON.parse(newTextDecoder().decode(bytes));}// { sub: '10000', name: '张三', nick: '小张🐱', role: 'editor', iat: 1790821813, exp: 1790825413 }

Node 里更简单,Buffer.from(seg, 'base64url').toString('utf8')一行解决。

2. exp 的单位是秒,Date.now() 是毫秒

JWT 规范里exp、iat、nbf都是Unix 秒。JS 的Date.now()是毫秒。前端判断过期时写成这样:

Date.now()>payload.exp// true —— 永远「已过期」Date.now()/1000>payload.exp// false —— 正确

反过来把 exp 直接丢给new Date()也一样错:

newDate(payload.exp)// 1970-01-21T17:27:05.413ZnewDate(payload.exp*1000)// 2026-10-01T03:30:13.000Z

看到「1970 年 1 月」的时间,基本就是这个问题。后端如果用 Java 的System.currentTimeMillis()去签 exp,也会签出一个几万年后才过期的 token,同样值得检查一遍。

3. 两台机器时钟差 90 秒,token 刚签出来就「还没生效」

签发和校验往往不在一台机器上。签发方时钟快了 90 秒,它写进去的iat、nbf在校验方看来就是「未来时间」:

constfast=sign({sub:'1',iat:now+90,nbf:now+90,exp:now+3690});verify(fast,{clockTolerance:0});// Error: not active yetverify(fast,{clockTolerance:120});// ok

现象是用户刚登录,第一个请求就 401,过一两分钟又好了。主流 JWT 库都有类似的时钟容差参数(叫法不同,clockTolerance、leeway、clockSkew之类),一般给几十秒到两分钟。容差不要给太大,它同时也在延长过期 token 的可用时间。

下面是这篇里用的verify,只依赖 Node 内置的crypto,用来说明校验要做哪几件事:

importcryptofrom'node:crypto';constb64url=(buf)=>Buffer.from(buf).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');functionverify(tok,{algorithms=['HS256'],key,clockTolerance=0,nowSec=Math.floor(Date.now()/1000)}={}){const[h,p,s]=tok.split('.');constheader=decodeSegment(h);if(!algorithms.includes(header.alg))thrownewError(`alg${header.alg}not allowed`);constexpect=b64url(crypto.createHmac('sha256',key).update(`${h}.${p}`).digest());consta=Buffer.from(s||''),b=Buffer.from(expect);if(a.length!==b.length||!crypto.timingSafeEqual(a,b))thrownewError('bad signature');constpl=decodeSegment(p);if(pl.nbf!==undefined&&nowSec+clockTolerance<pl.nbf)thrownewError('not active yet');if(pl.iat!==undefined&&pl.iat>nowSec+clockTolerance)thrownewError('issued in the future');if(pl.exp!==undefined&&nowSec-clockTolerance>=pl.exp)thrownewError('expired');returnpl;}

生产环境请直接用成熟的库,自己手写只适合理解原理。

4. 解得开不代表可信:改了 payload,解码照样成功

很多人第一次用 JWT 解码工具时会有点慌:「我的 token 谁都能解开?」是的,payload 只是编码,不是加密。所以不要往里放手机号、身份证号这类敏感信息。

反过来,解码成功也不说明 token 是真的。把 role 从 editor 改成 admin,签名原样拼回去:

consttampered=`${h0}.${b64url(JSON.stringify({...payload,role:'admin'}))}.${s0}`;decodeSegment(tampered.split('.')[1]).role// 'admin'verify(tampered,{key})// Error: bad signature

解码只是读,校验签名才是验。前端拿 token 里的信息做展示没问题,比如显示昵称、提前判断快过期了去刷新;但「这个用户是不是管理员」只能由后端校验签名之后决定,前端读到的 role 只能当提示用。

5. 校验时信任 header 里的 alg,会放过 alg: none

这是 JWT 最有名的一类问题,很多老代码和早期的库都栽过。header 里的alg是 token 自己说的,如果校验逻辑按它来选算法:

functionverifyTrustHeader(tok){const[h,p,s]=tok.split('.');constheader=decodeSegment(h);if(header.alg==='none')returndecodeSegment(p);// 问题在这// ...按 HS256 校验}constforged=sign({sub:'10086',role:'admin',exp:now+3600},{alg:'none'});verifyTrustHeader(forged).role// 'admin'verify(forged,{key})// Error: alg none not allowed

alg: none的 token 第三段是空的,不需要任何密钥就能造出来。正确做法是服务端自己固定允许的算法列表,不看 token 怎么说。同理,同时支持 HS256 和 RS256 的服务要特别小心,别让攻击者拿公钥当 HMAC 密钥去签。现在主流库默认都要求显式传算法,升级到新版本、别关掉这个检查就行。

6. 排查时先看这几个字段

拿到一个出问题的 token,我一般按这个顺序看:

字段看什么常见问题
alg是不是你预期的算法none、HS/RS 混用
exp换算成时间是否合理1970 年(单位错)、几万年后(签成了毫秒)
iat / nbf是否比当前时间晚签发方时钟快
sub / 自定义字段用户 id、角色是不是这个人串号、缓存了旧 token
长度放 Cookie 会不会超 4KBpayload 塞了太多东西

这个示例 token 有 216 个字符,正常。见过把整个权限列表塞进 payload 的,token 两三千字节,放 Cookie 里每个请求都要带,还可能撞到网关的请求头大小限制。

日常看 token 我用的是 福兮的 JWT 解析工具,粘进去就把 header 和 payload 按字段列出来,exp、iat会顺手换算成本地时间,中文和 emoji 也能正常显示(上面那个带「小张🐱」的 token 实测没问题)。解码在浏览器里完成,不发请求。

它的局限也说一下:它只解码、不验签,没有密钥也验不了,所以它告诉你的是「token 里写了什么」,不是「token 是不是真的」;时间按你电脑的时区显示,和服务器对时间时要自己换算。另外,生产环境的 token 是有效凭证,粘到任何网页工具之前都想一下,排查完最好让它失效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询