
最近折腾 API 认证,前两篇分别聊了 Basic Auth 和 API Key,这篇把 JWT(JSON Web Token)说清楚。和前面两位前辈相比,JWT 解决的问题更尖锐,也更有意思。
第二篇里说过,API Key 本质上就是一个长字符串,你的软件每次请求时把它亮给服务器看。能用,但有个隐藏代价:每次 Key 被使用,服务器都必须去数据库里查一下——这个 Key 有没有权限、有没有过期、有没有被禁用。JSON Web Token(JWT)把这个查询干掉了,因为所有信息都直接写在 Token 本身里。这篇文章先说清楚 JWT 解决的是什么问题,再把它放到整个 Web 认证体系里去看。
第一篇讲了 Basic Auth——每次请求都带着用户名和密码。第二篇讲了 API Key——把那个可以反复使用的密码换成一个不透光的密钥字符串,用来标识应用而不是个人。
两种方案都默认了一个前提:服务器已经掌握了这个凭证的元数据,而且它必须去记住这些。
把 API Key 想象成一张寄存牌。牌本身不告诉你任何信息——就一个编号。想知道这张牌对应什么,管理员得走进后面的房间,找到对应的记录,然后读出来。没有那个房间,这张牌就是废纸。
那个"后面的房间"就是数据库。去查它叫做数据库往返——服务器暂停,发一个问题给数据库,等答案回来才能继续。
对大多数应用来说,每次请求查一次数据库完全没问题。但想想 API Key 每次到达时服务器实际上在验证什么:
这三个答案都在数据库里,不在 Key 本身。所以每次请求在干任何正事之前都要触发一次往返。
规模一大,问题就出来了,主要在两种场景:
更根本的问题是状态。服务器必须查数据库才能理解一个凭证——这叫有状态服务器——它自己做不了决定,永远需要那个"后面的房间"。
JWT 把这个模型翻转了。与其给服务器一张毫无意义的牌子、逼着它去查详情,不如给服务器一张把详情直接印在上面的牌子——而且盖了章,能证明这内容没被伪造。
继续用寄存的比喻:JWT 不像寄存牌,更像登机牌。登机牌上已经写明了你的名字、航班、座位和登机时间,就印在纸上。登机口的工作人员不用打电话给总部查你的信息——他读一下登机牌,核验真假就行。信息跟着旅客走。
这个性质有个精确的名字。服务器只靠 Token 本身(加上它本来就持有的验签密钥)就能验证 Token——这叫无状态——它不需要记住每个请求的状态,也不需要那个"后面的房间"。后面拆 JWT 三段结构时会详细说它怎么证明自己没被伪造。现在先记住那个核心交换:
API Key 把元数据存在数据库里,给你一个指向它的指针。JWT 把元数据存在 Token 里,给你一个信任它的办法。
这个交换很有力,但——第三篇会诚实探讨——它不是免费的。印在 Token 上的信息没法抹掉,当你需要提前作废一个 Token 时,这就是个真正的麻烦。
每项技术都在特定时刻、因特定原因出现。JWT 也不例外——它诞生时,Web 刚刚从一种默默主导了十几年的模式转向新的方向。
2000 年代的大部分时间里,Web 认证都靠服务端 Session。你登录时,服务器在自己的内存或数据库里创建一条会话记录,然后给你的浏览器一个小标识——一个 Session Cookie。之后每次请求,浏览器把这个 Cookie 送回来,服务器查一下就知道你是谁。
这跟上面寄存牌的逻辑一样:Cookie 是个没意义的编号,服务器掌握所有真实信息。它在网站只有一台服务器时工作得很好。但它把每个登录用户绑死在某台特定的机器内存上。如果把你的流量分散到多台服务器,就出问题了——用户在服务器 A 登录了,到了服务器 B 就是陌生人,因为服务器 B 根本没创建过那条会话记录。
2010 年代初的两个趋势让服务端 Session 越来越难堪。
第一,应用不再是一台服务器了。它们分布在大量机器上,还拆成了微服务——很多小程序,每个只干一件事。一个共享的中央会话存储成了瓶颈,每个服务都得查它。
第二,客户端变了。Web 不再只有浏览器加载完整页面了。它有了单页应用(SPA)——网站只加载一次,之后表现得像桌面程序——还有原生移动端应用,经常调用完全不同的域名上的 API。Cookie 设计上就绑死在单个域名,面对这个新世界很吃力。
行业需要一个凭证,任何服务器都能自己验证,不需要共享的会话存储,而且不锚定在某个域名上。答案就是让 Token 自带身份证明。
这项工作在 IETF(互联网工程任务组,制定互联网底层协议标准的机构)内进行,属于 OAuth 工作组——同一个组也在设计下一代授权标准。他们需要一个紧凑、安全的 Token 格式来传递身份信息,JWT 就是为这个需求打造的。
结果在 2015 年 5 月发布为 RFC 7519——这是一份正式规范,定义了什么是 JSON Web Token。RFC(Request for Comments,征求意见)是 IETF 用于互联网标准的文档格式;尽管名字听起来像草案,一旦发布就是权威定义。
值得注意的是:JWT 从来不是单独发明的。它是一个小系列标准的核心——统称 JOSE(JavaScript Object Signing and Encryption,JavaScript 对象签名与加密),还包括如何给 Token 签名(JWS)、加密(JWE)、表示加密密钥(JWK)等子标准。
不过在实际使用中,"JWT" 是人人都在用的叫法,我们这里也这么用。
规范只有在被采用时才有意义,而 JWT 的采用速度快得异常。身份公司 Auth0(2013 年成立)大量围绕 JWT 构建产品和开发者教育,并大力推广这个格式——包括 jwt.io,一个免费的在线调试器,让开发者粘贴一个 Token 进去就能立刻看到解码后的内容。对大量工程师来说,这个工具是他们第一次亲手接触这个格式。
从此 JWT 在生态里扩散开来。它成了 OpenID Connect 的默认 Token 格式——这是建立在 OAuth 2.0 之上的身份层,也是这个系列后面会讲到的主题。云平台采用它做服务间认证。几乎每种主流编程语言的 Web 框架都发布了创建和验证它的库。RFC 7519 发布后没几年,JWT 从一个提案变成了默认选项。
这些背景不是冷知识——它解释了你即将拆解的这个技术的来龙去脉。JWT 长成现在这样,是因为它为特定的世界设计的:没有共享内存的分布式系统,客户端分散在各种域名和设备上。
这段历史也解释了它核心的权衡取舍。JWT 被设计成让服务器独立做决定,不需要回调中央存储。这种独立性正是让 JWT 快速、可扩展的特性——也是,正如我们后面会看到的,让 Token 一旦签发就难以取消的特性。优势与劣势是同一个设计决策的两面。
有了这些背景,我们可以拆开一个真实的 Token,看看那三个点分开的部分到底装了什么。
这是文章的核心。一旦你真的看清了 JWT 是什么,后面所有主题——声明、签名、验证、安全——都容易理解得多。所以我们把一个真实的 Token 拆开,一点一点看。
JWT 写出来是一长串看起来吓人的乱码:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBZGEifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
看着随机,其实不是。仔细看会发现两条点把它分成三段:
header . payload . signature
这个结构永远不变。每个 JWT,到处都一样,就是三段用点分隔:
开头的登机牌比喻在这里用得刚好。Header 是顶上写明文件类型的小字。Payload 是你真正关心的部分——你的名字、航班、座位。Signature 是官方印章,证明这张登机牌是真的,不是自己打印的。
JWT 看起来读不懂,原因不是加密。是编码——这个区别很重要。
JWT 的 Header 和 Payload 只是编码,不是加密。它用的是一种叫 Base64URL 的方案——一种只使用字母、数字和少量符号的表示方式,在网页地址(URL)里可以安全使用。
这就引出了关于 JWT 最重要——也最常被误解——的事实:
JWT 的内容不是保密的。任何持有 Token 的人都能解码 Header 和 Payload,读到里面所有东西。签名阻止人们修改 Token,但没法隐藏它。
后面会讲这个安全后果。现在先记住:JWT 防伪造,不防阅读。永远不要把密码、信用卡号或其他敏感信息放进去。
取我们这个示例 Token 的第一段,做 Base64URL 解码,得到一小块 JSON——JavaScript 对象表示法,就是现代 Web 上到处都在用的简洁可读的 key: value 格式:
{
"alg": "HS256",
"typ": "JWT"
}
Header 是关于 Token 本身的元数据。它回答两个问题:
alg(algorithm,算法)——签名是怎么生成的。这里是 HS256。后面会详细讲算法选择;现在先把它当作一个标签,标明用了什么方法去盖那个章。typ(type,类型)——这是什么类型的对象。对 JWT,就只是 "JWT"。Header 很短,它主要是让接收服务器知道怎么检查签名。
解码第二段,得到另一块 JSON——这才是真正干活的部分:
{
"sub": "12345",
"name": "Ada"
}
Payload 承载声明(Claims)——Token 做出的一个个陈述。"声明"这个词选得很准确:每个条目都是一个声明,一个断言"这是真的"。这里的 Token 声明了两件事:这个 Token 的主题(sub)是用户 12345,那个用户的名字是 Ada。Payload 是身份、权限、过期时间和你自定义数据待的地方。
第三段是唯一真正用到密码学的地方——也是让 JWT 可信的部分。
签名解决的问题是这样的。我们已经知道任何持有 Token 的人都能读 Payload。但他们能修改它吗?——把 "sub": "12345" 改成 "sub": "99999",假冒另一个用户?答案是"能"。能做到不被发现吗?最重要的答案是"不能"——因为签名让这种攻击必然失败,服务器知道怎么应对。
服务器首次创建 Token 时,会做一个计算。它把编码后的 Header 和编码后的 Payload 拼起来,中间加点,然后用只有服务器知道的密钥送进一个单向数学函数。对我们的 HS256 示例来说,这个函数是 HMAC-SHA256。输出就是签名:
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
)
这个函数有两个特性让整个机制 work:
所以攻击者把 Payload 改成 "sub": "99999" 时,Token 上的签名和修改后的内容对不上了。服务器用篡改后的 Payload 重新算签名,和 Token 里的签名一比较,不匹配——Token 被拒绝。因为攻击者没有密钥,他也算不出伪造 Payload 的有效新签名。
这节最值得带走的核心思想:
签名不让 JWT 保密。它让 JWT 诚实。它保证 Header 和 Payload 是签发服务器写的原样,自签发以来没有被碰过。
消除"这是魔法"这种感觉最好的办法是完全不用库、只靠标准工具来构建一个 Token。用 Python 演示:
import base64, json, hmac, hashlib
def base64url_encode(data: bytes) -> str:
# 标准 Base64,再做成 URL 安全版本:去掉末尾的 '=' 填充。
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def create_jwt(payload: dict, secret: str) -> str:
# 1. Header:声明算法和类型。
header = {"alg": "HS256", "typ": "JWT"}
# 2. 把 Header 和 Payload 用 Base64URL 编码。
h = base64url_encode(json.dumps(header).encode())
p = base64url_encode(json.dumps(payload).encode())
# 3. 用 HMAC-SHA256 和密钥对 "header.payload" 签名。
sig = hmac.new(secret.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
# 4. 用点连接三部分。
return f"{h}.{p}.{base64url_encode(sig)}"
token = create_jwt({"sub": "12345", "name": "Ada"}, secret="my-secret-key")
print(token)
这就是整个机制。没什么隐藏的。JWT 就是两块 JSON,编码后安全传输,加上一份从它们算出来的签名。代码里的四个编号步骤就是全部故事。
手写 Token 是理解 JWT 的正确方式。是在真实软件里使用 JWT 的错误方式。
生产环境永远要用维护良好、经过审计的库——Python 里用 PyJWT 或 python-jose,其他主流语言也有直接对应的库。同样任务变成一个调用:
import jwt # PyJWT 库
token = jwt.encode(
{"sub": "12345", "name": "Ada"},
"my-secret-key",
algorithm="HS256"
)
这不是偷懒——是安全。手写版本创建 Token 是对的,但安全地验证 Token 满是微妙的坑。一个粗糙的验证器可能被骗着接受伪造 Token、过期 Token 或不该信任的 Token。成熟的库已经把这些教训吸收进去了;你从零写的代码还没有。
所以手写一个来理解它,然后就再也不要生产环境里这么干了。
Token 结构清楚了,可以深入看它最重要的部分——Payload,以及它承载的声明。
前面拆开 JWT 找到了 Payload——中间那段,装着 Token 的真实内容。Payload 里每个信息单元叫一个声明(Claim)。这节讲这些声明是什么,标准定义了哪些,以及你自己创建哪些。
术语听起来正式,但道理很简单。声明是 Token 关于其主题的一个陈述——Payload 的 JSON 里的一组 key: value。Token "声明"这些是真的,而签名让这个声明可信。
Payload 就是一堆这样的陈述:
{
"iss": "auth.myapp.com",
"sub": "12345",
"aud": "api.myapp.com",
"iat": 1716120000,
"exp": 1716123600,
"role": "editor",
"tenant_id": "acme-corp"
}
其中一些键——iss、sub、aud、iat、exp——是 JWT 标准的一部分,在哪里意思都一样。另一些——role、tenant_id——是应用自己发明的。这个区分是这节的核心思想。
RFC 7519 定义了一小套注册声明(Registered Claims)。这些不是必须的——JWT 可以省略任何一项——但如果你用了,必须按标准赋予的含义来。它们用三个字母的短名,让 Token 保持紧凑。
值得知道的有七个:
| 声明 | 名称 | 含义 |
|---|---|---|
iss |
Issuer,签发者 | 谁创建并签发了这个 Token。 |
sub |
Subject,主题 | 这个 Token 关于谁或什么——通常是用户 ID。 |
aud |
Audience,受众 | 这个 Token 发给谁——哪个服务应该接受它。 |
exp |
Expiration Time,过期时间 | 超过这个时刻后 Token 必须被拒绝。 |
nbf |
Not Before,在此之前不生效 | 这个时刻之前 Token 还未生效。 |
iat |
Issued At,签发时间 | Token 创建的时刻。 |
jti |
JWT ID | 这个特定 Token 的唯一标识符。 |
其中几个值得细看,因为它们做的事比第一眼