1. 先搞清“认证”和“授权”:这个不弄清楚,谈安全全是空谈
我见过不少团队在技术评审时争得面红耳赤,一方力挺 JWT,说它无状态、扩展性好;另一方坚持 OAuth2,说它是行业标准、安全可靠。但吵到后来你会发现,两边讨论的根本不是一回事——一个在说令牌格式,一个在说授权框架。这就好比争论“汽车和汽油谁跑得快”,听起来有道理,但压根不是一个维度的问题。
1.1 认证和授权到底有什么区别
先把基础概念钉死,后面所有讨论都建立在这两个词上。
认证解决的是“你是谁”的问题。用户拿用户名密码换一个身份凭证,服务器验证凭证有效后,确认你就是你。在 API 场景里,认证的结果通常是一个令牌,请求带上它,服务端就知道这请求来自哪个用户。
授权解决的是“你能干什么”的问题。身份确认之后,系统需要判断你这个用户有没有权限执行某个操作——比如读取订单、修改商品、删除评论。授权关注的是权限边界,和你是谁没关系,只和你被赋予的角色或资源权限有关。
用一句话概括:认证在前,授权在后;认证是入口,授权是闸门。很多安全漏洞之所以发生,就是因为把这两个概念混在了一起,要么跳过认证直接谈授权,要么在认证阶段做了不该做的权限判断。
1.2 JWT 和 OAuth2 在架构中分别扮演什么角色
JWT 是一种令牌的编码格式,全称是 JSON Web Token。它把用户的身份信息和一些元数据签名后打包成一个字符串,服务端通过验签就能确认令牌内容没有被篡改过。JWT 解决的是“认证信息如何安全地传递和验证”的问题。
OAuth2 是一套授权框架,它定义的是“第三方应用如何安全地获取用户资源访问权限”的完整流程。它不关心令牌是什么格式,只关心授权流程怎么走——用户在哪儿登录、令牌发给谁、令牌怎么刷新、怎么吊销。
实际项目中两者通常是配合使用而不是二选一:OAuth2 负责定义整个授权流程,JWT 作为流程中实际传递的访问令牌格式。你完全可以做一个只用 JWT 的认证系统(自建登录接口直接签发 JWT),也可以做一个完全不用 JWT 的 OAuth2 系统(令牌用不透明的随机字符串存数据库)。把这两个概念放上擂台比谁更安全,本质上是个伪命题。
1.3 为什么“JWT vs OAuth2”是个伪命题
JWT 和 OAuth2 根本不在同一层做事。如果非要比,合理的问法应该是:“用 JWT 做认证令牌还是用不透明 Token?用 OAuth2 做授权流程还是自己拍脑袋设计一个授权方案?”
我做了几年后端,也接手过不少历史项目,一直在各种 JWT 配置里调过期时间、修刷新逻辑,也在各种 OAuth2 流程里排查过重定向回调的坑。我的经验是:安全与否的决定性因素从来不是选型——不管选 JWT 还是 OAuth2,配置不当一样出安全事故;真正决定安全的,是你有没有理解这套机制里的每一条约束为什么要存在。
2. JWT 的安全边界:看懂令牌结构,才能避开那些经典漏洞
JWT 这玩意儿看着简单,一个字符串三段式结构,但正因为简单,反而容易让人掉以轻心。我在代码评审里见过太多签名算法没锁死、过期时间调到 30 天、payload 里塞手机号身份证的骚操作,每一个都是等着被薅的漏洞。
2.1 JWT 结构拆解:那三段字符串到底装了什么
一个 JWT 长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InpoYW5nc2FuIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.sQ0YJvL2RZQnD2D2GtF2QYwFgH3dD8cMp6QKk0WvFUA用点分成三段:
- Header:声明算法和令牌类型。
- Payload:存放实际的数据声明,比如用户 ID、角色、过期时间。
- Signature:对前两段做签名的结果,用来防止内容被篡改。
Payload 里的exp(过期时间)、iat(签发时间)、aud(受众)、iss(签发者)、sub(主体)这几个标准声明,是安全性的关键,很多人只用了exp就以为高枕无忧了。
就安全而言,sub和aud的校验至关重要。aud声明这个令牌是给哪个服务用的。如果你有多个服务共用同一套签名密钥,一个服务签发令牌给用户,用户拿着令牌去调另一个服务,另一个服务如果不校验aud,那这个令牌就被跨服务滥用了。我在实际项目里排查过一个诡异问题:用户在 A 服务正常登录,请求 B 服务却偶发 401,查了半天是某个服务忘了校验aud导致的权限穿透。
2.2 签名算法选择:为什么我一律建议用 RS256 而不是 HS256
JWT 最常见的两种签名算法:
| 算法 | 密钥类型 | 验证方式 | 适用场景 |
|---|---|---|---|
| HS256 | 对称密钥 | 同一个密钥既签名又验签 | 单服务、密钥只在服务端 |
| RS256 | 非对称密钥 | 私钥签名,公钥验签 | 多服务、服务之间不能共享私钥 |
HS256 的问题在于签名密钥和验证密钥是同一个。你在微服务架构里如果所有服务共享同一个 HS256 密钥,任何一个服务被攻破,整个系统的令牌都能被伪造。另一个经典攻击是算法混淆:攻击者把令牌的 Header 从RS256改成HS256,如果你服务端在验证时没有明确锁定算法,就会尝试用公钥(通常可以从 JWT 提供方暴露的接口拿到)作为 HMAC 密钥去验证签名,直接就能伪造合法令牌。
生产环境我一直启用 RS256。私钥只保存在认证中心,下游服务只需要公钥就能验签,公钥泄了顶多能验签不能伪造。Python 里我用python-jose或者PyJWT都能轻松处理 RS256。
2.3 过期时间、Audience、Issuer 的合理配置
这节直接给结论,都是我踩过坑之后总结的参数。
payload = { "sub": str(user.id), # 用户标识,必须是用户唯一ID "aud": "api-backend", # 受众,明确指定这个令牌给哪个服务 "iss": "auth-service", # 签发者,明确谁是签发方 "exp": int(time.time()) + 3600, # 过期时间,短一点 "iat": int(time.time()), # 签发时间,用于计算令牌年龄 "jti": uuid4().hex, # 唯一ID,用于吊销跟踪 }关于过期时间,登录类令牌 30 到 60 分钟我觉得比较合理。太短用户频繁重新登录体验极差,太长窗口期太大被盗风险高。配合 Refresh Token 使用的话,短期访问令牌可以更激进一点,15 分钟也不是不行。
Service-to-Service 的服务账号令牌可以根据需要放宽,但一定要设置aud做服务隔离。内部通信令牌给到一个小时以上就要谨慎,你得清楚这个令牌能访问哪些资源面。
2.4 那些年的 JWT 漏洞:除了算法混淆还有哪些坑
密钥泄露无感知。JWT 号称无状态,但代价是签发能力和验证能力都在密钥手里。私钥一旦泄露,攻击者可以签发任意身份的令牌,你连撤销的能力都没有,只能换密钥让所有用户重新登录。所以密钥管理一定要用专门的密钥管理服务或者环境变量注入,绝不能硬编码进代码仓库。
用户被删除后令牌还能继续用。JWT 无法直接吊销,因为它是无状态的。用户删号了、改密码了、踢下线了,你没做黑名单的话,他手里的旧令牌能一直用到过期。这就需要引进令牌版本号或黑名单机制。
Payload 塞了敏感信息。有人为了方便把邮箱、手机号、头像链接直接塞进 JWT。JWT 默认只做签名不做加密,Base64 解码是个人都能看到内容。我就见过有人把身份证号放进 JWT 的,这等于把用户隐私挂在帽子上逛街。敏感信息要么不放,要么就用 JWE(JSON Web Encryption)做加密。
3. OAuth2 的安全机制:授权码 + PKCE 才是现代应用标准姿势
OAuth2 的本质是把“用户名密码”和“第三方应用”隔离开。用户不需要把密码交给第三方应用,而是通过授权服务器颁发一个授权码,再由应用换成访问令牌。这套设计最核心的价值在于:密码只经过授权服务器,不经过任何第三方。
3.1 四种授权模式,前端应用该用哪个
OAuth2 定义了四种主流模式:
| 模式 | 适用场景 | 安全级别 |
|---|---|---|
| Authorization Code | 有后端的 Web 应用 | 高 |
| Authorization Code + PKCE | 纯前端 SPA、移动端 | 高 |
| Implicit | 已被淘汰的旧模式 | 低 |
| Client Credentials | 服务间通信 | 中(仅限后端) |
| Password | 资源所有者密码模式 | 低(不推荐) |
Implicit 模式已经被 OAuth 官方明文弃坑,原因在于访问令牌直接出现在 URL 回调里,浏览器历史、日志插件都会留下令牌痕迹。Password 模式在老系统里见过不少,用户把用户名密码直接给了客户端,纯纯把 OAuth2 的安全性卖了一半。
现在做 SPA 或者小程序,我的建议就是 Authorization Code + PKCE。PKCE 引入了一个code_verifier和一个code_challenge,前者是客户端自己生成的随机串,后者是通过哈希或明文变换后发给授权服务器的值。换令牌时客户端要带着原始code_verifier,授权服务器校验它能对得上code_challenge,这样即使授权码被截获,没有code_verifier也换不到令牌。
3.2 完整走一遍 Authorization Code + PKCE 流程
我用 FastAPI 实现过几次这个流程,跑通一次就会对 OAuth2 的安全设计有很直观的理解。
import secrets import hashlib import base64 import httpx from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse app = FastAPI() # 第1步:生成 PKCE 参数并引导用户跳转授权服务器 @app.get("/login") async def login_with_provider(): # code_verifier:一段高熵随机字符串 code_verifier = secrets.token_urlsafe(64) hashed = hashlib.sha256(code_verifier.encode()).digest() code_challenge = base64.urlsafe_b64encode(hashed).rstrip(b"=").decode() # code_verifier 必须存起来,后面回调时要用 # 生产环境建议存在服务端 session 或者带一次性凭据的 cookie 里 request.session["code_verifier"] = code_verifier authorize_url = f"https://provider.com/oauth2/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/callback&scope=openid&code_challenge={code_challenge}&code_challenge_method=S256" return RedirectResponse(authorize_url) # 第2步:回调时换取令牌 @app.get("/callback") async def oauth_callback(code: str, request: Request): code_verifier = request.session.pop("code_verifier") async with httpx.AsyncClient() as client: token_resp = await client.post( "https://provider.com/oauth2/token", data={ "grant_type": "authorization_code", "code": code, "client_id": "YOUR_CLIENT_ID", "redirect_uri": "http://localhost:8000/callback", "code_verifier": code_verifier, }, ) tokens = token_resp.json() return tokens这套流程的安全性在于:授权码是短时效一次性凭证,过期即废,同时换令牌时必须匹配 PKCE 校验器;所以即使授权码在 URL 回调链路中被截获,攻击者也拿不到code_verifier。令牌换回来直接放在后端内存或 HttpOnly Cookie 使用,不要暴露给前端 JS,这样 XSS 拿不到令牌。
3.3 Refresh Token 的双刃剑:永不过期的令牌最危险
Refresh Token 解决的问题是:访问令牌有效期短,不能总让用户重复登录。但 Refresh Token 因为有效期长,也成了攻击者的头号目标。
我做项目时一直坚持两个原则:
Refresh Token 必须支持轮换。每次用 Refresh Token 换取新令牌时,同时吊销旧 Refresh Token,并发新 Refresh Token。这样泄露的 Refresh Token 用一次就会被发现并失效。
Refresh Token 必须支持复用检测。如果检测到同一个 Refresh Token 被用了两次,坚定认为整个令牌族已被泄露,直接吊销这一族所有令牌,让用户重新登录。
async def refresh_access_token(refresh_token: str, user_id: int, token_service): stored = await token_service.get_token_by_value(refresh_token) if stored is None: raise UnauthorizedError("刷新令牌不存在或已被吊销") if stored.family_id in await token_service.get_revoked_families(user_id): # 整个令牌家族已经列入黑名单,强制重新登录 await token_service.revoke_all_family_tokens(stored.family_id) raise UnauthorizedError("检测到令牌复用,已吊销该家族全部令牌") if stored.is_revoked: # 旧的 Refresh Token 已经被用过,分两种情况: # 如果是同一个请求并发导致正常,可走幂等处理 # 如果确实是被攻击者回放,需要吊销全族 await token_service.revoke_all_family_tokens(stored.family_id) raise UnauthorizedError("刷新令牌已使用过,已强制下线") new_access_token, new_refresh_token = await token_service.issue_new_token_pair(user_id) await token_service.revoke_token(stored.id) return new_access_token, new_refresh_tokenRefresh Token 存数据库是最稳妥的,用的时候查库确认有效性。虽然这牺牲了部分无状态特性,但刷新操作频率低,一次查询的代价完全可接受。换取回来的新 Refresh Token 返回给客户端时,建议通过 HttpOnly Cookie 下发。
3.4 回看标题:JWT 和 OAuth2 到底谁更安全
到这儿,答案应该很清楚了:JWT 只是一个令牌格式,OAuth2 是一套授权框架,硬要比谁更安全没有意义。真正要比的,是你在 JWT 体系里面有没有做算法锁定、过期时间、黑名单;在 OAuth2 里面有没有走标准授权码流程、用 PKCE、做令牌轮换和吊销。安全不是一个开关,而是一组配置的组合。
4. Python 代码实战:基于 FastAPI 搭建一个带 JWT 认证的授权服务
前面讲了这么多理论,直接上一套可落地的代码,用的是我在生产环境打磨过的方案。整体设计是:FastAPI 做 API 层,JWT 做访问令牌,Refresh Token 存数据库做吊销和轮换。
4.1 项目结构和依赖
先用一个虚拟环境装依赖:
python -m venv venv source venv/bin/activate pip install fastapi uvicorn pyjwt python-jose pydantic[speedups]我习惯同时装PyJWT和python-jose,前者轻量适合核心逻辑,后者涉及 JWKS 场景会更方便。你按需选择,核心逻辑差不多。
4.2 JWT 的签发与校验:关键代码逐段拆解
# auth/jwt.py import time import uuid from typing import Optional import jwt from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key SECRET_DIR = "/etc/secrets/rs256_private.pem" # 私钥文件请挂在安全的地方 PUBLIC_DIR = "/etc/secrets/rs256_public.pem" ALGORITHM = "RS256" def load_rsa_keys(): with open(SECRET_DIR, "rb") as f: private_key = load_pem_private_key(f.read(), password=None) with open(PUBLIC_DIR, "rb") as f: public_key = load_pem_public_key(f.read()) return private_key, public_key private_key, public_key = load_rsa_keys() def create_access_token(user_id: str, role: str) -> str: """签发短期访问令牌""" payload = { "sub": str(user_id), "role": role, "aud": "api-backend", # 受众:本服务集群 "iss": "auth-center", # 签发者 "exp": int(time.time()) + 15 * 60, # 15 分钟过期 "iat": int(time.time()), "jti": uuid.uuid4().hex, # 令牌唯一ID } return jwt.encode(payload, private_key, algorithm=ALGORITHM) def decode_access_token(token: str) -> dict: """校验访问令牌,任何一步失败都抛异常""" try: claims = jwt.decode( token, public_key, algorithms=[ALGORITHM], # 锁定算法,防混淆攻击 audience="api-backend", # 校验受众 issuer="auth-center", # 校验签发者 options={ "require": ["exp", "sub", "jti"], }, ) return claims except jwt.InvalidTokenError as exc: raise UnauthorizedError("令牌无效: " + str(exc))这套代码里有几个细节值得解释:
算法锁定。algorithms=[ALGORITHM]这一行是防混淆攻击的关键,只允许 RS256 验签,就算攻击者把 Header 改成 HS256 也直接拒绝。不锁定算法,等于给攻击者留了后门。
受众和签发者校验。audience和issuer两个参数被很多人忽略。如果你只有一套服务,可能觉得无所谓;但一旦上了微服务,没有aud校验,A 服务签发的令牌在 B 服务也能用,权限边界直接糊成一团浆糊。
4.3 Refresh Token 的存储与轮换实现
Refresh Token 用数据库表存储,用一个简单模型搞定:
# auth/models.py from datetime import datetime import uuid from sqlalchemy import Column, String, DateTime, Boolean, ForeignKey from .database import Base class RefreshToken(Base): __tablename__ = "refresh_tokens" id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) user_id = Column(String(36), index=True) token_hash = Column(String(64), unique=True, index=True) # 只存哈希,不存明文 family_id = Column(String(36), index=True) # 令牌族,用于复用检测 expires_at = Column(DateTime, nullable=False) revoked = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow)Refresh Token 本身是一个随机的高熵字符串,例如secrets.token_urlsafe(48)。数据库里我不会存明文,而是存 SHA-256 哈希,就算数据库泄露攻击者也拿不到可用令牌。每次验证时先算哈希再查库。
4.4 FastAPI 里的依赖注入:一个装饰器轻松搞定保护
FastAPI 的依赖注入系统处理认证非常方便。我习惯把当前用户信息挂在请求对象上,业务代码直接从request.state取。
# auth/dependencies.py import hashlib from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session from .jwt import decode_access_token from .database import get_db security = HTTPBearer(auto_error=False) def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), ): if credentials is None or credentials.scheme.lower() != "bearer": raise HTTPException(status_code=401, detail="缺少认证令牌") claims = decode_access_token(credentials.credentials) # 顺便做一层简单的黑名单检查:如果带有黑名单版本号则拒绝 # 详情见第5章 user_id = claims["sub"] request.state.user_id = user_id request.state.role = claims.get("role", "user") return user_id业务接口直接吊依赖:
# routers/orders.py from fastapi import APIRouter, Depends from auth.dependencies import get_current_user router = APIRouter(prefix="/orders", tags=["orders"]) @router.get("") async def list_orders(user_id: str = Depends(get_current_user)): # 这里直接从 user_id 拉订单数据 # 不用再手动解析令牌 return await get_orders_by_user(user_id)4.5 密钥管理和签名算法需要注意的细节
RS256 密钥对生成命令:
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -in private_key.pem -pubout -out public_key.pem私钥文件权限必须是600,放到独立密钥目录或者走密钥管理服务注入,绝对不要提交进 Git 仓库。我在团队里推过一个简单规矩:任何包含私钥的文件一旦进过 Git 历史,不管后来删没删,都视为已泄露,必须重新生成密钥对。
5. 常见问题与排查技巧实录
做认证授权这几个月,积累了一些真实踩坑记录,每一件都曾经让线上告警响得原地震动。
5.1 令牌过期后前端到底该怎么处理
很多前端团队的写法是在请求里看到 401 就直接跳登录页。这在访问令牌 15 分钟就过期的情况下简直就是体验灾难——用户正写一半的帖子突然被踢下线,草稿全丢。
更合理的方案:前端拦截器把 401 分成两类。一类是访问令牌过期,此时用 Refresh Token 去静默换新令牌,换完重放原请求;另一类是 Refresh Token 也失效,此时才跳登录页。
请求 401 -> 尝试刷新令牌 -> 成功则重放请求 -> 失败则跳转登录页实际项目中,刷新动作建议用一个单例 Promise 做并发合并,避免多个请求同时触发多个刷新请求,造成令牌被轮换后旧的过期令牌反复刷新导致冲突。我见过线上日志里同一个刷新请求冒出几十个实例,全是对 Refresh Token 的并发挤压。
5.2 刷新令牌被滥用和并发刷新撞车怎么破
并发场景下,如果两个请求同时带着同一个 Refresh Token 来刷新,服务端处理不当很容易出现其中一个成功、另一个报“已被使用”。重新登录还好,但如果是移动端弱网重试,这种现象其实是正常的业务逻辑,不该归为攻击。
我的处理方式是给 Refresh Token 加上一个小窗口:允许同一族在短时间内并发刷新,但不允许旧令牌在换发新令牌之后继续反复使用。并发请求到了,先查该令牌是否已轮换;如果已轮换,把新令牌留给并发请求的其一,而不是立刻吊销全族。只有超过窗口期还存在旧令牌复用痕迹,才走全族吊销的逻辑。
5.3 用户被删除或改密码后,手里的令牌依然有效怎么解决
JWT 无状态的痛点之一就在这里。用户改密码后他手里的旧访问令牌还能用 15 分钟(取决于过期时间),这在安全要求高的系统不可接受。我用的方案是引入令牌版本号:
- 用户表加一列
token_version,默认 1。 - 改密码、踢下线、删除账号时让
token_version自增,并在 Redis 或内存中更新版本黑名单。 - JWT 的 payload 里带上
tv字段。 - 校验令牌时对比
tv与用户当前版本号,不一致直接拒绝。
def is_token_valid(claims: dict, user): if claims.get("tv", 1) != user.token_version: raise UnauthorizedError("令牌版本已失效,请重新登录") return True有了版本号,你不用把所有旧令牌收回来黑名单,只要在接口层做一次数据库或缓存里的版本对比,效率高也好管理。
5.4 无状态令牌省了查库,但排查问题也难了
JWT 的“无状态”特性确实省了每次请求查数据库的开销,但它给你挖了一个坑:令牌一旦签出去,服务端就没有令牌状态的记录了。审计日志需要记录谁拿了什么权限干过什么,而 JWT 过期就查不到历史。这时候就要靠jti字段打日志,把jti、用户 ID、操作时间、接口路径记到审计表,出问题时才能追到具体某次请求对应的令牌。
5.5 一个典型事故:公钥当密钥用,算法混淆攻击
某次做安全自查时,我们复现了一次针对 HS256 的经典攻击。测试服务器对称密钥就存在配置文件里,攻击者只需要把拦截到的 JWT 的 Header 改成HS256,然后用服务端公钥(我们 JWT 生成器的 JWKS 接口是公开的)当作 HMAC 密钥重新签名一个身份替换的令牌。因为服务端在jwt.decode时只传了 token 和密钥没限定算法,默认允许 HS256,攻击者就成功伪造了一个管理员身份令牌。
这个事故给团队留下的教训非常具象:
- 服务端验签必须锁定算法,禁止自动发现编码头。
- 用 RS256 而不是 HS256,公钥想当对称密钥用,也得是先能拿到私钥才行。
- 一切与密钥相关的接口都必须有访问控制,不能裸奔。
6. 回到项目本身:Python 生态里认证授权方案的选型建议
如果你是刚开始搭一个新的 Python 服务,没有历史包袱,我给的选型建议是:
自建认证系统:用 OAuth2 的授权码 + PKCE 流程做授权,JWT(RS256)做访问令牌格式,Refresh Token 轮换 + 复用检测做续期和吊销。这套组合是目前平衡开发成本和安全性的最优解,能应对大部分业务场景。
接入第三方身份提供商:直接把 OAuth2 授权码 + PKCE 流程转发给身份提供商,不要自己造用户名密码体系。
内部纯后端服务:Client Credentials 模式加上服务账号令牌,令牌仍然用 JWT 格式。
关于“最新实践”这个说法,我个人的看法是:认证和授权领域的技术演进其实不算快,这两年最大的变化是PKCE 从“推荐”变成 OAuth 2.1 的“必须”,以及业界对 Refresh Token 轮换机制的共识更强了。至于 JWT 和 OAuth2 谁更安全,以后再有人拿这个当标题来问我,我会直接反问一句:你准备怎么用它?
在你自己的项目里,先把认证和授权的概念分清楚,再决定用什么组合。安全不是选了某个技术就自动获得的能力,而是每一层配置、每一项校验、每一次吊销策略叠加出来的结果。这个道理,放到任何技术栈里都成立。