上一代网站用 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 用起来方便,坑也不少,重点记这四条:

配套机制是刷新令牌(refresh token):短命的 access token 过期后,客户端用长期有效的 refresh token 换新的,既保证安全又避免频繁登录。典型模型是 access token 15 分钟有效、refresh token 7 天有效,刷新接口验证 refresh token 后签发新的 access token;用户改密码或退出登录时,把 refresh token 加入黑名单即可实现"吊销"——这弥补了 JWT 无法主动失效的短板。这属于进阶话题,但理解了 JWT 再看它就不难。最后用一张表对比三种方案:

维度Session普通 TokenJWT
状态存储服务器服务器客户端
服务器扩展需共享会话存储需共享 Token 库天然无状态
吊销能力删会话即可删记录即可难,靠过期
适合场景传统服务端渲染简单内部系统前后端分离、App、微服务

7. 总结与练习

本文的演进主线:Session 把状态放服务器 → Token 把状态交给客户端 → JWT 用签名保证"信息可信"。你亲手实现了 HS256 的签发与校验,理解了 exp、恒定时间比较、算法固定这些关键细节。练习建议:

💡 选 Session 还是 JWT,先问自己:需要主动踢人下线吗?需要,Session 更顺手;追求无状态扩展、多端复用,上 JWT。没有银弹,只有取舍。