JWT + Refresh Token:access 短 + refresh 长 + 安全撤销

无状态鉴权用 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 风险。两种防御:

  1. SameSite=Strict cookie:跨站请求不发 cookie(前面已经设了)
  2. 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']
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。