
1. 为什么要自己写一个JWT解码工具1.1 在线解码网站的三个痛处做了几年后端接口开发JWT这个东西几乎天天见。用户登录后发一个令牌前端存起来每次请求带上后端验一下签名放行。本来这个流程很顺但一到联调和排错就烦了后端同事说“你这个token过期了”前端同事说“我明明刚登录的”两边一吵最后都得把token粘到一个在线解码网站上看看里面到底放了什么。我一开始也用在线工具用多了就发现几个实际问题。第一是安全顾虑。JWT的payload虽然没加密只是Base64Url编码但里面经常放着userId、userName、角色、邮箱这类业务信息。把token粘贴到第三方网站等于把这些信息交到陌生人手里。尤其公司内网环境token里可能还带内部系统的标识这种东西外传本身就是违规。偶尔一两次没啥感觉天天贴就有点心虚了。第二是环境限制。很多公司开发机是内网隔离的或者访问外网要走审批在线解码网站根本打不开。有些项目还涉及客户现场进了客户网络之后能上的网站更少。这时候手边没有离线解码工具就只能自己用命令行一行一行地解效率很低。第三是频率问题。一个接口调不通可能要连续解码四五个token对比其中field的差异。在线网站一次只能看一个还没有历史记录全凭肉眼记。要是能有个本地小工具支持命令行脚本化把token当参数传进去直接输出JSON接在调试流程里就方便太多了。这篇文章就是把我自己写的这个JWT解码工具完整拆开讲讲从JWT结构原理、Base64Url的坑到命令行版和Web版的实现代码再到SPA项目联调、token续签、漏洞排查这些场景里它到底能帮上什么忙。代码都能直接抄走改改就能用适合后端、前端、测试以及任何需要经常和JWT打交道的同学。1.2 先搞清楚边界解码和解签是两码事在动手写工具之前必须先明确一个概念JWT解码和JWT验签完全不是一回事。JWT总共三段用点号分隔Header头部、Payload负载、Signature签名。前两段都只是把JSON做了一遍Base64Url编码任何拿到token的人都能直接解开看到原文。这本身不是设计缺陷JWT本来就没打算对内容加密它靠签名保证的是“内容没被篡改”不是“内容不被人看见”。所以“解码工具”能做的是把前两段还原成可读的JSON再加上把第三段签名原样显示出来。而“验签”需要对方拿到服务端的密钥拿着密钥重新计算签名比对一致才说明token是可信的。普通的前端调试、接口排查场景往往是拿不到密钥的也不需要验签能把内容看清就已经解决了80%的问题。在后续的内容里我会在需要验签的地方单独说明。而且从最近社区里的讨论来看JWT相关内容里出现频率很高的几个方向——SPA项目中的JWT验证码实现、token自动续签、JWT漏洞总结——每一个场景里一个随时能用的本地解码工具都是最基本的调试基础设施。先把这层地基打牢后面处理那些复杂问题才不会两眼一抹黑。2. JWT的解码原理三段字符串怎么变成可读信息2.1 先解剖一个真实token空谈原理不如直接看例子。下面这段是一个典型的JWTeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用点号拆开就是三段第一段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9Base64Url解码后是{alg:HS256,typ:JWT}告诉解析方这个token用的签名算法是HS256类型是JWT。第二段eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQBase64Url解码后是{sub:1234567890,name:John Doe,iat:1516239022}这里就是业务声明Claimsub是主题iat是签发时间。第三段SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c签名由Header和Payload加上密钥一起计算出来的。实际生产环境里的payload会比这个复杂得多常见的claim不外乎这几类Claim含义典型值iss签发者某个服务名或域名sub面向的用户用户IDaud接收方客户端ID或服务标识exp过期时间秒级时间戳1735689600nbf生效时间早于此时间不生效1735686000iat签发时间1735686000jti唯一标识UUID解码工具做的事本质上就是把前两段做一次Base64Url解码然后用JSON格式化输出。2.2 Base64Url和标准Base64的差异大多数人对Base64都不陌生图片转字符串、二进制转文本都会用到。但JWT用的不是标准Base64而是它的变体Base64Url两者就三点区别对比项标准Base64Base64Url第62个字符-第63个字符/_末尾填充用补齐到4的倍数通常去掉为什么这么改因为JWT经常出现在URL参数、请求头这些场景里和/在URL里会造成歧义在Cookie或某些参数值里也不那么友好。所以Base64Url干脆把这三个字符都换掉让token字符串可以在URL里裸奔。这个差异直接带来一个解码时的常见坑直接把token里的某一段丢给标准Base64解码器大概率报错。我在初版工具里吃过这个亏后面第6章会专门讲。正确的Python解码姿势是这样import base64 def decode_segment(segment: str) - bytes: # 先补回填充字符长度必须是4的倍数 padding * (4 - len(segment) % 4) segment segment padding # 用 urlsafe 方法解码而不是标准 base64.b64decode return base64.urlsafe_b64decode(segment)这里先补再解码顺序不能反。有些库比如Python的base64.urlsafe_b64decode对缺失padding的行为不一致有的会自动补有的直接抛异常自己手动补齐是最稳妥的做法。2.3 签名段为什么默认不校验第三段Signature是JWT里最核心的安全部分但解码工具默认不去验它原因很直接验签需要密钥而解码工具的使用者通常没有密钥。签名生成逻辑大致长这样HMACSHA256( base64url(header) . base64url(payload), secret )也就是说服务端用密钥对“前两段字符串拼接起来的结果”做一次HMAC计算输出结果做Base64Url编码就得到第三段。解码工具的解码动作不涉及密钥所以第三段只能原样显示出来。但有一个细节值得做进工具里自动判断签名段是否为空。如果某段token只有两段Header.Payload没有第三段说明它很可能是一枚algnone的伪造token这在漏洞排查场景里是一个非常重要的信号。我在工具里加了提示签名段为空时输出醒目的警告。这本身不需要密钥却能在第一时间帮你发现异常。3. 核心实现从零开始写一个能用的JWT解码器3.1 命令行版Python脚本50行搞定我先写了命令行版原因很简单调试接口的时候最常见的工作流是抓包、复制token、丢给工具看内容。命令行工具可以和抓包流程无缝衔接也能配合grep、jq做管道处理。完整代码如下可以直接保存为jwt_decode.py使用#!/usr/bin/env python3 import sys import json import base64 import argparse from datetime import datetime, timezone def decode_segment(segment: str) - bytes: padding * (4 - len(segment) % 4) segment segment padding return base64.urlsafe_b64decode(segment) def format_time(timestamp): try: return datetime.fromtimestamp(timestamp, tztimezone.utc).strftime(%Y-%m-%d %H:%M:%S UTC) except Exception: return N/A def decode_jwt(token: str): parts token.split(.) if len(parts) not in (2, 3): print([错误] token格式不合法应为 Header.Payload.Signature 三段结构) sys.exit(1) try: header json.loads(decode_segment(parts[0]).decode(utf-8)) payload json.loads(decode_segment(parts[1]).decode(utf-8)) except Exception as e: print(f[错误] 解码失败: {e}) sys.exit(1) print( Header ) print(json.dumps(header, indent2, ensure_asciiFalse)) print(\n Payload ) print(json.dumps(payload, indent2, ensure_asciiFalse)) if len(parts) 3: print(\n Signature ) print(parts[2]) print(f\n签名算法: {header.get(alg, unknown)}) else: print(\n[警告] 未发现签名段疑似 algnone 伪造token) # 时间字段友好输出 if exp in payload: print(f\nexp过期时间: {format_time(payload[exp])}) if iat in payload: print(f\niat签发时间: {format_time(payload[iat])}) def main(): parser argparse.ArgumentParser(description本地JWT解码工具) parser.add_argument(token, helpJWT字符串) args parser.parse_args() decode_jwt(args.token) if __name__ __main__: main()用起来很简单python jwt_decode.py eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c输出会自动把exp和iat转成人类可读的时间这在排查“token是不是过期了”时非常直观。代码只有50行左右异常处理也做了基本覆盖唯一需要注意的就是确保Python版本在3.7以上。3.2 Web版粘贴即解析方便SPA联调命令行版虽然好用但前端同事更习惯打开一个页面粘贴token直接看结果。所以我顺手写了个单文件HTML版本双击就能用不需要任何服务器也没有外部依赖。核心逻辑在JavaScript里。要处理的关键问题是浏览器自带的atob函数用的是标准Base64字符集遇到Base64Url的-_需要先转换而且Unicode中文会有乱码风险。处理方式如下function base64UrlDecode(input) { // 把Base64Url字符集替换回标准Base64 let base64 input.replace(/-/g, ).replace(/_/g, /); // 补回padding while (base64.length % 4) { base64 ; } // 解码成二进制字符串 const decoded atob(base64); // 处理UTF-8中文乱码 const bytes new Uint8Array(decoded.length); for (let i 0; i decoded.length; i) { bytes[i] decoded.charCodeAt(i); } return new TextDecoder(utf-8).decode(bytes); }完整网页就是下面这个单文件!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleJWT 本地解码工具/title style body { font-family: monospace; max-width: 800px; margin: 40px auto; padding: 0 20px; } textarea { width: 100%; height: 100px; } pre { background: #f5f5f5; padding: 15px; border-radius: 6px; overflow-x: auto; } .warning { color: #c00; font-weight: bold; } /style /head body h3JWT 本地解码工具数据不会离开浏览器/h3 textarea idinput placeholder粘贴JWT token到这里/textarea brbr button onclickdecode()解码/button button onclickdocument.getElementById(result).innerHTML;document.getElementById(input).value;清空/button pre idresult/pre script function base64UrlDecode(input) { let base64 input.replace(/-/g, ).replace(/_/g, /); while (base64.length % 4) { base64 ; } const decoded atob(base64); const bytes new Uint8Array(decoded.length); for (let i 0; i decoded.length; i) { bytes[i] decoded.charCodeAt(i); } return new TextDecoder(utf-8).decode(bytes); } function decode() { const token document.getElementById(input).value.trim(); const parts token.split(.); const result document.getElementById(result); if (parts.length 2) { result.textContent 格式不正确JWT应包含至少两段; return; } try { let header JSON.stringify(JSON.parse(base64UrlDecode(parts[0])), null, 2); let payload JSON.stringify(JSON.parse(base64UrlDecode(parts[1])), null, 2); let html bHeader:/b\n header \n\nbPayload:/b\n payload; if (parts.length 3) { html \n\nbSignature:/b\n parts[2]; } else { html \n\nspan classwarning警告不存在签名段疑似algnone攻击/span; } result.innerHTML html; } catch (e) { result.textContent 解码失败 e.message; } } /script /body /html页面打开后直接在textarea里粘贴token点解码结果就出来了。前端同学在SPA项目联调时从localStorage里把token复制出来粘到这个页面几秒钟就能看清里面放了哪些字段不用再麻烦后端帮忙查。3.3 设计取舍为什么故意不做验签可能有人会问既然要做工具为什么不把验签功能也加上输入密钥直接验证token有效性我的考虑是验签需要密钥而密钥本身就是敏感信息。如果一个解码工具既支持解码又要求输入密钥很容易形成“把密钥到处粘贴”的坏习惯。日志里、截图里、聊天记录里密钥泄露的风险反而增加了。所以我的工具里刻意没做验签功能。解码就是解码看清内容、识别明显异常就够了。真正需要验签的场景应该用服务端语言内置的JWT库去验证而不是指望一个字符串工具。这个边界在安全实践里很重要后面第5章还会展开讲。4. 实战场景token续签、SPA联调与多环境排查4.1 token续签调试算清楚exp和iat的剩余时间热词里有一个高频话题是关于JWT实现token续签的。续签的基本思路其实不复杂token过期前或者过期后前端拿一个refresh_token去换新的access_token。但在实际调试中最容易出的问题就是时间窗口算不对。比如服务端把access_token的有效期设成30分钟前端判断还剩5分钟时自动刷新。结果线上总有人在20分钟的时候就被踢出登录查了半天发现是服务端发的token里exp减去iat根本不是1800秒而是300秒。这种问题用解码工具一眼就能看出来。我的命令行工具会在解码时自动计算python jwt_decode.py token | grep -E exp|iat再把时间戳粘贴到工具里看具体时间。为了方便我后来又加了个小功能如果payload里有exp额外输出“距离过期还剩多少秒/分钟”。实现不复杂就是exp - time.time()但对排查续签问题太有用了。另外在调试续签接口时往往需要连续解码access_token和refresh_token两个token对比两者的iss、aud、exp等字段是否一致。命令行工具每次只能传一个token我就用shell做了个简单循环一次处理多个for t in $(cat tokens.txt); do python jwt_decode.py $t; echo ---; done配合这个批量对比token内容完全不是问题。4.2 SPA项目里的JWT验证码实现与前端排查热词里提到SPA项目开发之JWT验证码实现实际场景一般是用户输入账号密码加验证码后端校验通过后签发JWT前端把token存起来后续请求都在Authorization头里带上。前端最常见的问题有三个第一个token存哪。有人存localStorage有人存sessionStorage还有直接塞Cookie的。各有各的考虑。我在排查问题时会先问一句你的token到底存哪了因为很多时候接口报401原因就是前端取token的key写错了或者刷新页面后sessionStorage被清空了。第二个token没放进请求头。Axios拦截器写法有误或者在某个接口里单独设置headers恰好把拦截器的逻辑覆盖掉了。这种问题解码工具帮不上忙但你可以把实际发出的请求头抓出来看——把token拿出来解码检查一下内容是不是对的至少能排除token本身的问题。第三个前端依赖token里的用户信息做页面渲染但信息不准。比如用户改名后页面还显示旧名字因为token里payload的name是登录时写入的没有跟着更新。解码工具能快速确认前端拿到的信息来自哪个claim字段联调时定位责任边界很管用。4.3 多环境共用token签名算法和claim差异排查公司通常有dev、test、prod多套环境每套环境的JWT密钥和配置都可能不一样。最尴尬的情况是开发环境签发了一个token拿到测试环境去联调后端验签直接失败。拿解码工具一解发现两边Header里的alg或者Payload里的iss字段都不一样问题原因就清楚了。还有一次同事遇到401百思不得其解。token解码后看payload发现aud是A服务的标识但请求打到了B服务服务端验aud不通过就拒绝了。这种错误人的肉眼很难发现但解码工具把payload格式化输出后字段值一览无余问题几秒钟就定位了。这些场景看起来简单但在实际项目里都是每天可能遇到的。一个本地解码工具核心价值不是功能有多酷而是在排查问题的时候能让你在10秒内看清token的真实内容不用求人、不用外传、不用瞎猜。5. 解码工具与JWT安全能帮你看到什么不能替你决定什么5.1 常见JWT漏洞与解码工具的辅助排查近期的JWT漏洞讨论不少我在实际工作中也接到过安全团队要求自查的情况。JWT相关的漏洞种类其实有限解码工具虽然没有密钥、不能完全验证安全性但能帮你快速筛查一批表面异常。漏洞类型外在表现解码工具怎么辅助发现修复建议algnonetoken只有两段无签名工具直接警告“不存在签名段”服务端禁止algnone弱密钥爆破HS256签名被离线破解只能发现算法是HS256需结合爆破工具使用长随机密钥并定期轮换算法混淆服务端误用RS256公钥验证HS256签名Header里alg显示为HS256但服务端预期RS256固定算法白名单不解信任意alg密钥硬编码前后端代码里出现密钥字符串解码工具无法直接发现需代码扫描使用环境变量或密钥管理服务过期时间过长exp与iat差值极大解码后时间字段友好输出肉眼能发现exp设为合理值建议30分钟到2小时敏感信息泄露payload含手机号、身份证等格式化输出后一目了然JWT只放必要字段敏感信息放服务端表格里提到的“算法混淆”和“algnone”用解码工具看不签名也能发现蛛丝马迹。有一次我们自查时拿解码器解一个内网测试token发现Header里alg直接是none查了服务端JWT库的配置发现竟然允许alg:none。虽然这只是配置库的默认宽松策略但配合解码工具几乎一瞬间就暴露了。5.2 解码工具自身的安全边界工具本身也要注意几条纪律第一工具必须本地运行不联网、不上传。我在Web版页面里特意写了一行提示“数据不会离开浏览器”就是为了强调这一点。本地HTML文件没有后端接口天然不会外传数据。第二不要在界面里留历史记录。有的工具为了方便会保存历史但对带敏感payload的token来说历史记录就是安全隐患。我的工具故意不做这个功能用完清空。第三不要为了“展示能力”把token贴到群里。解码工具让你看清token内容是好事但token本身是身份凭证截图、聊天记录都可能被其他人看到。正确的做法是在本地解码、本地分析不要外传原始token。5.3 一次真实的漏洞排查过程复盘今年年初我们对一个老项目做过一次JWT安全自查。流程大概是这样的先在网关日志里拉了一批访问异常、返回401/403的请求把Authorization头里的token提取出来逐个丢进解码工具。结果发现一个很反常的现象有几个token的Header里alg是HS256Payload里iss写的却是auth-service-v1但线上服务已经升级到auth-service-v2了。继续解码更多token又发现同一批token的exp时间相差很大有的设置了7天有效期。这明显是历史遗留服务签发出来的。后来追查代码确认旧服务还在用一套硬编码密钥签发token新服务换了算法和密钥导致旧token在切换后全部验签失败。如果不是解码工具快速暴露了iss、alg、exp这些细节我们可能还要在日志里翻半天。工具本身没有直接“修复”任何漏洞但它让排查过程从“盲人摸象”变成了“定向扫描”。这就是它在安全场景里的真正价值帮你迅速看清对象然后由你做进一步判断。6. 实现过程中的几个坑以及后续优化方向6.1 坑一Base64 padding缺失导致解码失败写初版CLI工具时我直接调了Python的base64.b64decode去解Header段结果对真实token时不时报错。排查发现JWT的Base64Url通常会去掉填充但标准解码器要求输入长度是4的倍数。差了1到3个字符的时候解码直接失败。这个坑的经典用法是先算len(segment) % 4补上对应的。但要注意如果segment长度已经是4的倍数再补4个反而会出错所以必须用4 - len % 4的结果当余数为0时补0个。我在前面代码里写的就是这个逻辑直接抄就行。Web版里同样有这个坑JavaScript的atob对缺失padding同样敏感所以我也写了while (base64.length % 4) { base64 ; }。补多了不行补少了也不行这个细节是解码器能不能稳定工作的关键。6.2 坑二中文在payload里变成乱码Web版第一版出来以后前端同事把token粘进去payload里的中文字段显示成乱码。原因在于atob返回的是Latin-1字符串直接JSON.parse或者innerHTML输出时UTF-8编码的汉字就会被拆成乱码字符。解决方案就是我代码里的那段先拿到atob的结果把每个字符转成Uint8Array再用TextDecoder(utf-8)解码。这样中文就能正常显示了。Python端其实也有对应的坑decode(utf-8)没问题但如果你在Windows上把输出重定向到GBK编码终端中文JSON输出可能仍然乱码。我的建议是输出时统一用ensure_asciiFalse加上UTF-8环境变量至少在Linux和macOS下是完全正常的。6.3 后续可以扩展的方向工具写完之后我还有几个改进想法按优先级排列支持从文件批量读取token输出结构化JSON或CSV方便做安全自查报表。加一个可选参数--verify-secret当用户手头确实有密钥时才执行HS256验签密钥从命令行参数或环境变量传入不落盘。增加对更多算法的标识识别如RS256、ES256在Header输出时标明算法类型提醒使用者注意算法混淆风险。给Web版加一个“复制Payload JSON”按钮方便把格式化后的内容粘到接口文档或工单里。不过目前这些扩展都没动原因是我认为工具的第一责任是简单可靠。加了太多功能反而容易分散注意力也可能引入新的风险。现在的版本命令行50行、网页单文件维护成本极低任何同事拿到都能看懂。6.4 几条实际使用建议最后分享几条我个人的使用习惯。第一不要在聊天工具里粘贴原始token哪怕只是给后端同事看也尽量先解码成payload再截图交流。第二本地保存的token文件用完即删尤其是线上环境的token避免长期堆积在临时目录里。第三定期关注服务端JWT库的版本和配置解码工具只能帮你发现问题真正修复还是要靠代码层面的加固。从最初的在线解码到自己写工具再到在项目里稳定用了大半年这个JWT解码工具虽然没有多高的技术含量但确实解决了日常调试中很大一块效率问题。如果你也经常和JWT打交道强烈建议顺手抄一份不管是命令行版还是Web版挑一个顺手的放好下次调接口的时候就知道它有多值了。