☰
自己动手写JWT解码工具:原理、实现与调试实战
2026/10/7 16:43:24 网站建设 项目流程

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

空谈原理不如直接看例子。下面这段是一个典型的JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

用点号拆开,就是三段:

  • 第一段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9:Base64Url解码后是{"alg":"HS256","typ":"JWT"},告诉解析方这个token用的签名算法是HS256,类型是JWT。
  • 第二段eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ:Base64Url解码后是{"sub":"1234567890","name":"John Doe","iat":1516239022},这里就是业务声明(Claim),sub是主题,iat是签发时间。
  • 第三段SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c:签名,由Header和Payload加上密钥一起计算出来的。

实际生产环境里的payload会比这个复杂得多,常见的claim不外乎这几类:

Claim含义典型值
iss签发者某个服务名或域名
sub面向的用户用户ID
aud接收方客户端ID或服务标识
exp过期时间(秒级时间戳)1735689600
nbf生效时间,早于此时间不生效1735686000
iat签发时间1735686000
jti唯一标识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,没有第三段),说明它很可能是一枚alg=none的伪造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, tz=timezone.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, indent=2, ensure_ascii=False)) print('\n=== Payload ===') print(json.dumps(payload, indent=2, ensure_ascii=False)) if len(parts) == 3: print('\n=== Signature ===') print(parts[2]) print(f'\n签名算法: {header.get("alg", "unknown")}') else: print('\n[警告] 未发现签名段,疑似 alg=none 伪造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', help='JWT字符串') 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 lang="zh-CN"> <head> <meta charset="UTF-8"> <title>JWT 本地解码工具</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> <h3>JWT 本地解码工具(数据不会离开浏览器)</h3> <textarea id="input" placeholder="粘贴JWT token到这里"></textarea> <br><br> <button onclick="decode()">解码</button> <button onclick="document.getElementById('result').innerHTML='';document.getElementById('input').value='';">清空</button> <pre id="result"></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 = '<b>Header:</b>\n' + header + '\n\n<b>Payload:</b>\n' + payload; if (parts.length === 3) { html += '\n\n<b>Signature:</b>\n' + parts[2]; } else { html += '\n\n<span class="warning">警告:不存在签名段,疑似alg=none攻击</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相关的漏洞种类其实有限,解码工具虽然没有密钥、不能完全验证安全性,但能帮你快速筛查一批表面异常。

漏洞类型外在表现解码工具怎么辅助发现修复建议
alg=nonetoken只有两段,无签名工具直接警告“不存在签名段”服务端禁止alg=none
弱密钥爆破HS256签名被离线破解只能发现算法是HS256,需结合爆破工具使用长随机密钥并定期轮换
算法混淆服务端误用RS256公钥验证HS256签名Header里alg显示为HS256,但服务端预期RS256固定算法白名单,不解信任意alg
密钥硬编码前后端代码里出现密钥字符串解码工具无法直接发现,需代码扫描使用环境变量或密钥管理服务
过期时间过长exp与iat差值极大解码后时间字段友好输出,肉眼能发现exp设为合理值,建议30分钟到2小时
敏感信息泄露payload含手机号、身份证等格式化输出后一目了然JWT只放必要字段,敏感信息放服务端

表格里提到的“算法混淆”和“alg=none”,用解码工具看不签名也能发现蛛丝马迹。有一次我们自查时,拿解码器解一个内网测试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是HS256,Payload里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_ascii=False加上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版,挑一个顺手的放好,下次调接口的时候就知道它有多值了。

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

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

立即咨询