在 JWT 中,Token 过期时间的控制是保障安全性的核心环节,核心逻辑是在 Token 的 Payload 中嵌入 exp 字段(过期时间戳),服务器验证时自动校验该字段。下面从「基础控制方式」「进阶优化方案」「实战代码」三个维度,讲清楚如何灵活、安全地控制 Token 过期时间,覆盖 Tornado 后端 + Vue/TS 前端的完整实现。
一、核心概念:JWT 过期时间的基础规则
1. 核心字段:exp(Expiration Time)
- exp 是 JWT 标准字段,值为Unix 时间戳(秒)(注意:不是毫秒!);
- 服务器验证 Token 时,会自动对比当前时间戳和 exp,若当前时间 > exp,则判定 Token 过期;
- exp 必须嵌入 Payload,且由服务器生成(前端无法篡改,因为签名会验证 Payload 完整性)。
2. 其他可选时间字段(辅助控制)
表格
字段 | 含义 | 作用 |
nbf | Not Before | Token 生效时间(在此时间前无法使用) |
iat
| Issued At | Token 签发时间(可用于校验 Token 签发时长) |
jti | JWT ID | Token 唯一标识(用于黑名单、刷新控制) |
二、Token 过期时间的 3 种控制方式(按场景选择)
方式 1:固定过期时间(基础版)
核心逻辑
登录时生成 Token,直接在 Payload 中设置固定的 exp(如 2 小时),适用于简单场景。
实战代码(Tornado 后端)
python
运行
import jwtimport timefrom typing import Dict# JWT 配置JWT_SECRET_KEY = "your_secure_key" # 生产环境放环境变量JWT_ALGORITHM = "HS256"JWT_EXPIRE_SECONDS = 7200 # 2小时过期(秒)def generate_token(user_info: Dict) -> str: """生成带固定过期时间的 Token""" payload = { "user_id": user_info["id"], "username": user_info["username"], "iat": int(time.time()), # 签发时间(可选) "exp": int(time.time()) + JWT_EXPIRE_SECONDS # 核心:过期时间戳 } # 生成 Token token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) return token# 验证 Token 时,自动校验 expdef verify_token(token: str) -> Dict | None: try: # jwt.decode 会自动校验 exp,无需手动判断 payload = jwt.decode( token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM], options={"verify_exp": True} # 显式开启 exp 校验(默认开启) ) return payload except jwt.ExpiredSignatureError: print("Token 已过期") return None except jwt.InvalidTokenError: print("Token 无效") return None方式 2:滑动过期(用户活跃时自动续期)
核心问题
固定过期时间的缺陷:用户正在操作时 Token 过期,体验差(比如 2 小时到期,用户第 1 小时 59 分操作,仍会过期)。
核心逻辑
- 每次用户发起有效请求时,检查 Token 剩余有效期;
- 若剩余时间 < 阈值(如 30 分钟),自动生成新 Token 并返回给前端;
- 前端替换旧 Token,实现 “活跃即续期”。
实战代码(Tornado 后端 + Vue/TS 前端)
后端(Tornado):滑动过期逻辑
python
运行
def refresh_token_if_needed(token: str) -> str | None: """检查 Token 剩余时间,不足则刷新""" try: payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM], options={"verify_exp": True}) current_time = int(time.time()) remaining_seconds = payload["exp"] - current_time # 阈值:剩余时间 < 30 分钟(1800 秒)则刷新 if remaining_seconds < 1800: # 生成新 Token(过期时间重置为 2 小时) new_payload = { "user_id": payload["user_id"], "username": payload["username"], "iat": current_time, "exp": current_time + JWT_EXPIRE_SECONDS } new_token = jwt.encode(new_payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) return new_token return None # 无需刷新 except Exception: return None# 在受保护的接口中使用class ProfileHandler(BaseHandler): @jwt_auth_required async def get(self): # 1. 获取旧 Token auth_header = self.request.headers.get("Authorization", "") old_token = auth_header.split(" ")[1] if auth_header else "" # 2. 检查是否需要刷新 Token new_token = refresh_token_if_needed(old_token) # 3. 返回用户信息 + 新 Token(如有) response_data = { "code": 0, "data": {"user_id": self.current_user["user_id"], "username": self.current_user["username"]} } if new_token: response_data["data"]["new_token"] = new_token # 携带新 Token self.write(response_data)前端(Vue + TS):自动更新 Token
typescript
运行
// src/utils/request.ts(修改响应拦截器)request.interceptors.response.use( (response) => { const data = response.data; // 若返回新 Token,自动替换旧 Token if (data.data?.new_token) { setToken(data.data.new_token); // 调用之前封装的 setToken 方法 } return data; }, (error) => { // 原有 401 处理逻辑... return Promise.reject(error); });方式 3:双 Token 机制(进阶版,生产环境推荐)
核心问题
滑动过期仍有缺陷:单一 Token 过期后用户需重新登录,体验差;且 Token 有效期过长(如 7 天)不安全,过短(如 15 分钟)频繁刷新影响性能。
核心逻辑
生成两个 Token:
- Access Token:短期有效(如 2 小时),用于接口访问(核心 Token);
- Refresh Token:长期有效(如 7 天),仅用于刷新 Access Token(不参与业务接口验证)。
完整流程
预览
查看代码
后端前端后端(Tornado)前端(Vue+TS)后端前端后端(Tornado)前端(Vue+TS)登录请求返回 access_token(2h) + refresh_token(7d)存储双 Token(refresh_token 存在 HttpOnly Cookie)携带 access_token 调用接口返回业务数据携带 access_token 调用接口返回 401(access_token 过期)携带 refresh_token 调用 /refresh_token返回新的 access_token(2h)携带新 access_token 调用接口返回业务数据
sequenceDiagram participant 前端(Vue+TS) participant 后端(Tornado) # 1. 登录生成双 Token 前端->>后端: 登录请求 后端->>前端: 返回 access_token(2h) + refresh_token(7d) 前端->>前端: 存储双 Token(refresh_token 存在 HttpOnly Cookie) # 2. 正常请求 前端->>后端: 携带 access_token 调用接口 后端->>前端: 返回业务数据 # 3. access_token 过期 前端->>后端: 携带 access_token 调用接口 后端->>前端: 返回 401(access_token 过期) # 4. 刷新 Token 前端->>后端: 携带 refresh_token 调用 /refresh_token 后端->>前端: 返回新的 access_token(2h) # 5. 继续请求 前端->>后端: 携带新 access_token 调用接口 后端->>前端: 返回业务数据后端前端后端(Tornado)前端(Vue+TS)后端前端后端(Tornado)前端(Vue+TS)登录请求返回 access_token(2h) + refresh_token(7d)存储双 Token(refresh_token 存在 HttpOnly Cookie)携带 access_token 调用接口返回业务数据携带 access_token 调用接口返回 401(access_token 过期)携带 refresh_token 调用 /refresh_token返回新的 access_token(2h)携带新 access_token 调用接口返回业务数据
实战代码
后端(Tornado):生成 / 刷新双 Token
python
运行
# 双 Token 配置ACCESS_TOKEN_EXPIRE = 7200 # 2小时REFRESH_TOKEN_EXPIRE = 604800 # 7天def generate_double_tokens(user_info: Dict) -> Dict: """生成 access_token + refresh_token""" current_time = int(time.time()) # 1. 生成 access_token access_payload = { "user_id": user_info["id"], "username": user_info["username"], "type": "access", # 标记 Token 类型 "exp": current_time + ACCESS_TOKEN_EXPIRE } access_token = jwt.encode(access_payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) # 2. 生成 refresh_token(额外存储到 Redis,便于主动失效) refresh_payload = { "user_id": user_info["id"], "type": "refresh", "exp": current_time + REFRESH_TOKEN_EXPIRE, "jti": str(uuid.uuid4()) # 唯一标识,用于 Redis 存储 } refresh_token = jwt.encode(refresh_payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) # 3. refresh_token 存储到 Redis(便于登出/过期清理) redis_client.setex( f"refresh_token:{refresh_payload['jti']}", REFRESH_TOKEN_EXPIRE, user_info["id"] ) return { "access_token": access_token, "refresh_token": refresh_token }# 刷新 Token 接口class RefreshTokenHandler(BaseHandler): async def post(self): # 1. 获取 refresh_token(优先从 HttpOnly Cookie 获取) refresh_token = self.get_cookie("refresh_token", "") or self.get_body_argument("refresh_token", "") if not refresh_token: self.set_status(401) self.write({"code": -1, "msg": "缺少 refresh_token"}) return # 2. 验证 refresh_token try: payload = jwt.decode(refresh_token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) # 校验 Token 类型和 Redis 中是否存在 if payload.get("type") != "refresh": raise jwt.InvalidTokenError("Token 类型错误") if not redis_client.get(f"refresh_token:{payload['jti']}"): raise jwt.ExpiredSignatureError("refresh_token 已失效") # 3. 生成新的 access_token user_info = {"id": payload["user_id"], "username": payload["username"]} new_access_token = generate_token(user_info) # 复用之前的 generate_token 方法 self.write({ "code": 0, "data": {"access_token": new_access_token} }) except jwt.ExpiredSignatureError: self.set_status(401) self.write({"code": -1, "msg": "refresh_token 已过期,请重新登录"}) except Exception: self.set_status(401) self.write({"code": -1, "msg": "refresh_token 无效"})# 登录接口返回双 Tokenclass LoginHandler(BaseHandler): async def post(self): # 验证用户名密码(省略) user_info = {"id": 1001, "username": "admin"} tokens = generate_double_tokens(user_info) # refresh_token 存入 HttpOnly Cookie(更安全) self.set_cookie( "refresh_token", tokens["refresh_token"], httpOnly=True, secure=True, # 生产环境开启 expires=time.time() + REFRESH_TOKEN_EXPIRE, path="/" ) # 返回 access_token(refresh_token 不返回前端,仅存在 Cookie) self.write({ "code": 0, "data": {"access_token": tokens["access_token"]} })前端(Vue + TS):刷新 Token 逻辑
typescript
运行
// src/utils/request.ts(优化 401 处理)import axios from 'axios';import { getToken, setToken, removeToken } from './jwt';import { ElMessage } from 'element-plus';const request = axios.create({ baseURL: 'http://localhost:8888/api', timeout: 5000});// 创建刷新 Token 的请求实例(避免拦截器循环)const refreshRequest = axios.create({ baseURL: 'http://localhost:8888/api', timeout: 5000, withCredentials: true // 携带存储 refresh_token 的 Cookie});// 用于防止重复刷新 Tokenlet isRefreshing = false;// 存储等待刷新 Token 的请求队列let requestQueue: Function[] = [];// 请求拦截器(携带 access_token)request.interceptors.request.use( (config) => { const token = getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error));// 响应拦截器(处理 401 刷新 Token)request.interceptors.response.use( (response) => response.data, async (error) => { const originalRequest = error.config; // 1. 排除刷新 Token 接口本身的 401 if (error.response?.status === 401 && originalRequest.url !== '/refresh_token' && !originalRequest._retry) { originalRequest._retry = true; // 标记已重试,防止循环 if (!isRefreshing) { isRefreshing = true; try { // 2. 调用刷新 Token 接口 const res = await refreshRequest.post('/refresh_token'); if (res.code === 0) { // 3. 存储新的 access_token setToken(res.data.access_token); // 4. 重新执行队列中的请求 requestQueue.forEach(cb => cb(res.data.access_token)); requestQueue = []; // 5. 重试原请求 originalRequest.headers.Authorization = `Bearer ${res.data.access_token}`; return request(originalRequest); } else { // refresh_token 也过期,需重新登录 removeToken(); ElMessage.error('登录状态已过期,请重新登录'); _window.location.href = '/login'; } } catch (e) { removeToken(); ElMessage.error('登录状态已过期,请重新登录'); _window.location.href = '/login'; } finally { isRefreshing = false; } } else { // 等待刷新 Token 完成后重试 return new Promise((resolve) => { requestQueue.push((newToken: string) => { originalRequest.headers.Authorization = `Bearer ${newToken}`; resolve(request(originalRequest)); }); }); } } return Promise.reject(error); });export { request };三、关键注意事项(避坑指南)
1. 时间戳单位(秒 vs 毫秒)
- JWT 的 exp 字段是秒级时间戳(Python time.time() 返回的是秒级浮点数,需转整数;JavaScript Date.now() 返回的是毫秒级,需除以 1000);
- 错误示例:前端用 Date.now() 直接赋值给 exp → Token 会立即过期(因为毫秒级时间戳远大于秒级)。
2. 前端不要信任本地过期判断
- 前端可解析 exp 字段做 “即将过期提示”(如剩余 5 分钟提示),但最终过期验证必须由后端完成(用户可篡改本地时间、伪造 Token)。
3. Refresh Token 的安全存储
- Refresh Token 是 “终极凭证”,前端需存在 HttpOnly + Secure Cookie 中(防 XSS 攻击),禁止存在 LocalStorage;
- 后端需将 Refresh Token 存储到 Redis(或数据库),登出 / 改密时主动删除,解决 JWT “无法主动销毁” 的问题。
4. 过期时间的合理选择
- Access Token:15 分钟~2 小时(越短越安全,结合 Refresh Token 不影响体验);
- Refresh Token:7 天~30 天(根据业务安全性要求调整)。
四、过期时间异常的排查
- Token 立即过期:检查 exp 时间戳单位(秒 / 毫秒)、服务器与客户端时间是否同步(建议用 NTP 同步服务器时间);
- Token 无法过期:检查验证时是否开启 verify_exp: True(JWT 库默认开启,但部分场景会手动关闭);
- Refresh Token 刷新失败:检查 Redis 中是否存在该 Token、exp 时间是否正确、Token 类型是否标记为 refresh。
总结(核心要点)
- 基础控制:通过 Payload 中的 exp 字段(秒级时间戳)设置过期时间,后端验证时自动校验;
- 体验优化:滑动过期(活跃时续期)或双 Token 机制(access_token 短期 + refresh_token 长期);
- 安全原则:Refresh Token 存在 HttpOnly Cookie,后端存储 Refresh Token 到 Redis 支持主动销毁;
- 避坑关键:注意时间戳单位(秒)、后端统一验证过期、禁止前端做核心过期判断。
