无状态鉴权用 JWT 是行业标准,但很多实现有安全 / UX 问题:
- access token 一次性给 24 小时:泄露后窗口太长
- 永远不轮换:被偷了没法停
- 把 JWT 放 localStorage:XSS 直接拿走
正确做法:access token 短(5-15 分钟)+ refresh token 长(7-30 天)+
refresh 时轮换。
1. 流程概览
登录 → 返回 access (15min) + refresh (7d)
API 请求 → 带 access token
access 过期 → 用 refresh 拿新的 access + 新的 refresh(轮换)
退出 → 把 refresh token 加入服务端 blacklist
2. 后端:FastAPI 例子
from datetime import datetime, timedelta
import secrets
import jwt
from fastapi import FastAPI, HTTPException, Depends, Response, Cookie
from passlib.hash import bcrypt
SECRET = 'long-random-secret-from-env'
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=7)
app = FastAPI()
def create_access_token(user_id: str) -> str:
return jwt.encode({
'sub': user_id,
'type': 'access',
'exp': datetime.utcnow() + ACCESS_TTL,
'jti': secrets.token_urlsafe(8),
}, SECRET, algorithm='HS256')
def create_refresh_token(user_id: str) -> tuple[str, str]:
jti = secrets.token_urlsafe(16)
token = jwt.encode({
'sub': user_id,
'type': 'refresh',
'exp': datetime.utcnow() + REFRESH_TTL,
'jti': jti,
}, SECRET, algorithm='HS256')
return token, jti
# 登录
@app.post('/auth/login')
def login(email: str, password: str, response: Response):
user = db.find_user(email)
if not user or not bcrypt.verify(password, user.password_hash):
raise HTTPException(401)
access = create_access_token(str(user.id))
refresh, refresh_jti = create_refresh_token(str(user.id))
db.save_refresh_token(user_id=user.id, jti=refresh_jti, ip=request.client.host)
# Refresh token 放 HttpOnly Cookie,不让 JS 碰
response.set_cookie(
'refresh_token', refresh,
httponly=True, secure=True, samesite='strict',
max_age=int(REFRESH_TTL.total_seconds()),
path='/auth',
)
return {'access_token': access, 'expires_in': int(ACCESS_TTL.total_seconds())}
3. 用 access token 访问 API
def current_user(authorization: str = Header(...)) -> User:
if not authorization.startswith('Bearer '):
raise HTTPException(401)
token = authorization[7:]
try:
payload = jwt.decode(token, SECRET, algorithms=['HS256'])
except jwt.ExpiredSignatureError:
raise HTTPException(401, detail='token_expired')
except jwt.InvalidTokenError:
raise HTTPException(401)
if payload['type'] != 'access':
raise HTTPException(401)
return db.get_user(payload['sub'])
@app.get('/api/me')
def me(user = Depends(current_user)):
return {'id': user.id, 'email': user.email}
4. 刷新
@app.post('/auth/refresh')
def refresh(response: Response, refresh_token: str = Cookie(None)):
if not refresh_token:
raise HTTPException(401)
try:
payload = jwt.decode(refresh_token, SECRET, algorithms=['HS256'])
except jwt.InvalidTokenError:
raise HTTPException(401)
if payload['type'] != 'refresh':
raise HTTPException(401)
user_id, jti = payload['sub'], payload['jti']
# 关键安全检查:refresh token 还在白名单里吗?
stored = db.get_refresh_token(user_id, jti)
if not stored or stored.revoked:
raise HTTPException(401, detail='refresh_revoked')
# 轮换:把旧 refresh 撤销,发新的
db.revoke_refresh_token(jti)
new_access = create_access_token(user_id)
new_refresh, new_jti = create_refresh_token(user_id)
db.save_refresh_token(user_id=user_id, jti=new_jti, ip=request.client.host)
response.set_cookie('refresh_token', new_refresh, httponly=True, ...)
return {'access_token': new_access, 'expires_in': int(ACCESS_TTL.total_seconds())}
每次刷新都换新 refresh token,旧的立即作废。被攻击者偷了旧 refresh 后,
用户下次正常刷新会让攻击者那份失效 → 攻击者被踢出。
5. 退出
@app.post('/auth/logout')
def logout(response: Response, refresh_token: str = Cookie(None)):
if refresh_token:
try:
payload = jwt.decode(refresh_token, SECRET, algorithms=['HS256'])
db.revoke_refresh_token(payload['jti'])
except jwt.InvalidTokenError:
pass
response.delete_cookie('refresh_token', path='/auth')
return {'ok': True}
access token 因为短(15 分钟)不需要单独撤销 —— 自然过期。
真要立即吊销,加一个"用户已注销 / 改密"的 token version 字段:
# 在 access token 里加 user.token_version
# 修改密码 / 强制下线时 user.token_version += 1
# 校验时检查 payload['ver'] == user.token_version
6. 前端处理
// 拦截器:401 时自动 refresh + 重试
api.interceptors.response.use(
r => r,
async (err) => {
if (err.response?.status === 401 && err.response?.data?.detail === 'token_expired') {
const r = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
if (r.ok) {
const { access_token } = await r.json()
saveAccessToken(access_token)
// 重试原请求
err.config.headers.Authorization = `Bearer ${access_token}`
return api.request(err.config)
}
// refresh 也失败 → 强制重新登录
window.location = '/login'
}
return Promise.reject(err)
}
)
注意:
- access token 存内存(不要 localStorage / sessionStorage —— XSS 风险)
- refresh token 走 HttpOnly cookie(JS 拿不到)
- API fetch 加
credentials: 'include'才会发送 cookie
7. 防 CSRF
refresh 通过 cookie 发 → CSRF 风险。两种防御:
- SameSite=Strict cookie:跨站请求不发 cookie(前面已经设了)
- CSRF token:refresh 要求 header 里带 CSRF token(额外参数)
SameSite=Strict 已经能挡 99% CSRF;想更严就加 CSRF token。
8. 算法选择
- HS256:HMAC + 共享 secret。最简单,单后端 OK
- RS256:RSA 非对称。分布式系统(验证方 ≠ 签发方)必选
- EdDSA / Ed25519:性能 + 安全比 RSA 更好(新版 PyJWT 支持)
不要用 alg: 'none'(无签名)—— 历史上多次造成漏洞。
9. 密钥管理
# 生成强 secret
openssl rand -base64 64
存 .env,不要进 git。
定期轮换:
1. 部署新 SECRET + 让代码支持 fallback 验证(旧 + 新两个 key)
2. 等所有老 token 过期(access 15 min + refresh 7d = 7 天后)
3. 移除老 SECRET
10. 单点登录 / SSO
业务复杂后用 OIDC(OpenID Connect)—— OAuth2 + 身份层。
Auth0 / Keycloak / Authelia / authentik 提供完整流程。
自己不要从零造身份系统。
踩过的坑
- access TTL 设 24h:泄露窗口太长。15 分钟 + refresh 是 sweet spot。
- refresh 不轮换:被偷了攻击者长期持有。每次刷新换新 refresh + 撤销旧的。
- 把 JWT 放 localStorage:XSS 直接读走。永远内存(access)+ HttpOnly cookie
(refresh)。 - 算法降级攻击:服务端用
jwt.decode(token, key)但没指定 algorithms
→ 攻击者用alg: 'none'假签名通过。永远指定algorithms=['HS256']。
登录后参与评论。