上一代网站用 Session 记登录态,现在的 App 和前后端分离项目几乎都在用 Token,而 JWT 是 Token 里最流行的形态。为什么要有这次演进?JWT 到底安不安全?本文从 Session 的痛点讲起,用 Python 标准库手写一套 JWT 的签发与校验,让你彻底看懂这个"签名信封",也为后面理解 OAuth2、单点登录这些进阶话题打下基础。
1. 从 Session 说起:痛点在哪
Session 的流程:登录成功 → 服务器生成随机 ID,把用户数据存在服务器内存或数据库里,再把 ID 通过 Cookie 下发 → 之后每次请求,服务器拿 ID 查"会话表"。它的问题是状态在服务器上:用户多了要存海量会话;服务器横向扩展时,得引入 Redis 共享会话;App 客户端还要手动管理 Cookie,体验也一般。
更麻烦的是多端场景:现在一个用户可能同时在网页、手机 App、小程序里登录,Session 体系的 Cookie 跟域名绑定,App 里根本没有"域名"这回事,还得自己实现一套"把 Session ID 存本地、请求时带上"的机制。Token 的思路正好绕开这些:把状态"打包"交给客户端保管。服务器不存任何会话,只要验证 Token 是"自己签发的、没被篡改、没过期",就信任里面的用户信息。这样一来服务器天然无状态,随便扩容,多端复用也只是一行请求头的事。
2. Bearer Token:最朴素的形态
最简单的 Token 就是一段随机字符串:登录时服务器生成并存进数据库,客户端请求时放进 Authorization 头:
# 请求头里携带 Token 的标准姿势
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx" https://api.example.com/me
Bearer 是"持有者"的意思,约定俗成表示"谁拿着这个 Token 谁就是本人"。与 Cookie 不同,Authorization 头不受域名限制,任何客户端、任何环境都能带,这是它适合多端场景的根本原因。这种方案的缺点是 Token 还是要在服务器上存一份(数据库或 Redis),本质没跳出 Session 的圈子——每次请求都要查一次库。JWT 的登场,就是为了彻底去掉服务器端的"会话表"。
3. JWT:自带信息的签名信封
JWT(JSON Web Token)把"信息"和"防伪"都塞进 Token 本身。它长这样,由两个点分成三段:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiY29kZWxhYiIsImV4cCI6MTc1MDAwMDAwMH0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
三段分别是:Header(声明算法,如 alg: HS256、typ: JWT)、Payload(业务数据,如用户 ID、过期时间)、Signature(用密钥对前两段签名)。Payload 只是 base64 编码,任何人都能解码看到内容,所以绝不放密码;但改内容会破坏签名,服务器一验就露馅。用一行命令就能解码看内容:
# 第二段是 base64url 编码的 JSON
echo "eyJ1c2VyIjoiY29kZWxhYiIsImV4cCI6MTc1MDAwMDAwMH0" | base64 -d
解码结果是 {"user": "codelab", "exp": 1750000000}——信息完全透明,这正是"签名信封"的含义:信封不保密,但封口有防伪。JWT 规范里定义了几个标准字段,exp 是过期时间、iat 是签发时间、iss 是签发方、aud 是受众,建议尽量用标准字段,别人读你的 Token 时一眼就懂。签名算法上,HS256 用共享密钥,适合单体服务;RS256 用非对称密钥,私钥签名、公钥验证,适合微服务架构——每个服务只要拿到公钥就能验证,不用共享同一个秘密。
4. 手写 JWT:签发与校验
我们用标准库实现最流行的 HS256 签名。先写签发函数:
import base64, hashlib, hmac, json, time
SECRET = b"my-secret-key" # 真实项目中放环境变量,绝不能写进代码
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def make_token(payload: dict, ttl: int = 3600) -> str:
header = {"alg": "HS256", "typ": "JWT"}
body = dict(payload, exp=int(time.time()) + ttl) # 内置过期时间
seg1 = b64url(json.dumps(header, separators=(",", ":")).encode())
seg2 = b64url(json.dumps(body, separators=(",", ":")).encode())
sig = b64url(hmac.new(SECRET, f"{seg1}.{seg2}".encode(), hashlib.sha256).digest())
return f"{seg1}.{seg2}.{sig}"
token = make_token({"user": "codelab"})
print("签发结果:", token)
关键行是签名:对"前两段拼接后的字符串"做 HMAC-SHA256 运算,密钥只有服务器知道。任何人对前两段动手脚,签名就对不上。注意 b64url 用 urlsafe_b64encode 并去掉 = 填充符,因为 JWT 要求 base64url 编码,+、/、= 这些字符在 URL 里都不安全。separators=(",", ":") 让 JSON 紧凑输出,减少体积。
再写校验函数,并现场演示"正常校验"和"篡改检测":
import base64, hashlib, hmac, json, time
SECRET = b"my-secret-key"
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def make_token(payload: dict, ttl: int = 3600) -> str:
header = {"alg": "HS256", "typ": "JWT"}
body = dict(payload, exp=int(time.time()) + ttl)
seg1 = b64url(json.dumps(header, separators=(",", ":")).encode())
seg2 = b64url(json.dumps(body, separators=(",", ":")).encode())
sig = b64url(hmac.new(SECRET, f"{seg1}.{seg2}".encode(), hashlib.sha256).digest())
return f"{seg1}.{seg2}.{sig}"
def verify(token: str):
try:
seg1, seg2, sig = token.split(".")
# 恒定时间比较,防止攻击者根据响应时间差猜签名
expect = b64url(hmac.new(SECRET, f"{seg1}.{seg2}".encode(), hashlib.sha256).digest())
if not hmac.compare_digest(expect, sig):
return None, "签名无效,Token 被篡改"
body = json.loads(base64.urlsafe_b64decode(seg2 + "=="))
if body.get("exp", 0) < time.time():
return None, "Token 已过期"
return body, None
except Exception as e:
return None, f"解析失败: {e}"
token = make_token({"user": "codelab"})
print("正常校验:", verify(token))
print("篡改校验:", verify(token[:-1] + ("A" if token[-1] != "A" else "B")))
校验三步走:验签名(防篡改)、查过期(防重用)、返回数据。两个细节值得学:hmac.compare_digest 用恒定时间比较,避免攻击者根据响应时间差猜测签名;base64url 解码时要补回被去掉的 == 填充符,否则长度不对会解码失败。运行后,"篡改校验"会返回"签名无效,Token 被篡改"——你只改了最后一个字符,签名就对不上了。
5. 实战:给接口加上 JWT 认证
把上面的逻辑组装成一个迷你 API:/login 签发 Token,/me 必须带有效 Token 才能访问。这段代码一次跑完,自带完整演示:
import base64, hashlib, hmac, json, time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import http.client
SECRET = b"my-secret-key"
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def make_token(user: str) -> str:
header = {"alg": "HS256", "typ": "JWT"}
body = {"user": user, "exp": int(time.time()) + 3600}
seg1 = b64url(json.dumps(header, separators=(",", ":")).encode())
seg2 = b64url(json.dumps(body, separators=(",", ":")).encode())
sig = b64url(hmac.new(SECRET, f"{seg1}.{seg2}".encode(), hashlib.sha256).digest())
return f"{seg1}.{seg2}.{sig}"
def verify(token: str):
try:
seg1, seg2, sig = token.split(".")
expect = b64url(hmac.new(SECRET, f"{seg1}.{seg2}".encode(), hashlib.sha256).digest())
if not hmac.compare_digest(expect, sig):
return None
body = json.loads(base64.urlsafe_b64decode(seg2 + "=="))
if body.get("exp", 0) < time.time():
return None
return body
except Exception:
return None
class App(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/login":
body = json.dumps({"token": make_token("codelab")}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
elif self.path == "/me":
auth = self.headers.get("Authorization", "")
user = verify(auth.removeprefix("Bearer "))
if user:
body = json.dumps({"ok": True, "user": user["user"]}).encode()
self.send_response(200)
else:
body = b'{"ok": false, "reason": "unauthorized"}'
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 0), App)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
def get(path, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
conn.request("GET", path, headers=headers or {})
r = conn.getresponse()
data = r.read().decode()
conn.close()
return r.status, data
print("不带 Token:", get("/me"))
_, login = get("/login")
token = json.loads(login)["token"]
print("带 Token:", get("/me", {"Authorization": "Bearer " + token}))
print("篡改 Token:", get("/me", {"Authorization": "Bearer abc.def.ghi"}))
server.shutdown()
输出依次是:不带 Token 返回 401、带 Token 返回 200 并带出用户名、篡改的 Token 返回 401。整个认证链路就三个动作:签发、携带、校验。auth.removeprefix("Bearer ") 从请求头里剥掉前缀取出 Token 本体;校验失败统一回 401,不给攻击者任何信息。这里顺便区分两个易混状态码:没带 Token 是 401(未认证),带了但权限不够是 403(无权限),两个数字背后的排查方向完全不同。想让服务器常驻,把最后三行换成 HTTPServer(("127.0.0.1", 8000), App).serve_forever() 即可。
用 curl 模拟真实调用也很直观:
# 1. 登录拿 Token
curl -s http://127.0.0.1:8000/login
# 2. 把上一步返回的 token 填进来,访问受保护接口
curl -s -H "Authorization: Bearer <上一步的token>" http://127.0.0.1:8000/me
# 3. 不带 Token:被 401 拒绝
curl -s http://127.0.0.1:8000/me
前端通常把 Token 存在 localStorage 或内存里,请求时统一塞进 Authorization 头:
fetch("/api/me", {
headers: { Authorization: "Bearer " + token }
}).then(r => r.json()).then(console.log);
6. JWT 的安全要点
JWT 用起来方便,坑也不少,重点记这四条:
- 密钥必须保密:HS256 是共享密钥签名,密钥泄露等于所有人都能伪造 Token,一定要放环境变量;
- Payload 不加密:别放密码、手机号等敏感信息,它只是 base64,谁都能解码;
- 算法混淆攻击:有些库允许客户端指定算法,攻击者把 HS256 换成
none就能免签名。服务端必须用白名单固定算法; - 过期时间必须设:JWT 一旦签发无法主动吊销,只能靠
exp让它自然失效,所以过期时间别设太长(常见 15 分钟到 2 小时)。
配套机制是刷新令牌(refresh token):短命的 access token 过期后,客户端用长期有效的 refresh token 换新的,既保证安全又避免频繁登录。典型模型是 access token 15 分钟有效、refresh token 7 天有效,刷新接口验证 refresh token 后签发新的 access token;用户改密码或退出登录时,把 refresh token 加入黑名单即可实现"吊销"——这弥补了 JWT 无法主动失效的短板。这属于进阶话题,但理解了 JWT 再看它就不难。最后用一张表对比三种方案:
| 维度 | Session | 普通 Token | JWT |
|---|---|---|---|
| 状态存储 | 服务器 | 服务器 | 客户端 |
| 服务器扩展 | 需共享会话存储 | 需共享 Token 库 | 天然无状态 |
| 吊销能力 | 删会话即可 | 删记录即可 | 难,靠过期 |
| 适合场景 | 传统服务端渲染 | 简单内部系统 | 前后端分离、App、微服务 |
7. 总结与练习
本文的演进主线:Session 把状态放服务器 → Token 把状态交给客户端 → JWT 用签名保证"信息可信"。你亲手实现了 HS256 的签发与校验,理解了 exp、恒定时间比较、算法固定这些关键细节。练习建议:
- 给
verify加一个aud(受众)字段校验,思考它能防住什么攻击; - 把 Token 的过期时间改成 5 秒,用 curl 观察过期前后的返回差异;
- 用
alg: none伪造一个 Token 打自己的服务器,确认它被拒——亲身理解算法混淆攻击为什么危险; - 研究一下你常用的框架(如 Flask 的 PyJWT、Express 的 jsonwebtoken)怎么配置,和自己手写的版本对照。
💡 选 Session 还是 JWT,先问自己:需要主动踢人下线吗?需要,Session 更顺手;追求无状态扩展、多端复用,上 JWT。没有银弹,只有取舍。