JWT的token泄露要如何应对
文章目录
- 前言
- ✅ 一、预防措施(防泄露)
- 🚨 二、应急响应机制(发现已泄露)
- 🔒 1. **启用 Token 黑名单机制**
- 🔁 2. **启用 Refresh Token 机制 + 旋转令牌**
- 📍 3. **强制下线机制**
- 🛡️ 4. **异常行为检测 + 风控联动**
- 🔁 5. **轮换密钥 / Key Rotation**
- 🧩 推荐 Token 架构模式(实践)
- ✅ 实践建议总结
- **基于 NestJS 实现 Refresh Token + 黑名单机制的方案**
- 📦 技术栈概览
- 🧠 架构设计核心思想
- ✅ 1. Token 签发逻辑(AuthService)
- ✅ 2. 登录 & 返回 Refresh Token(设置 HttpOnly Cookie)
- ✅ 3. 刷新接口:/auth/refresh
- ✅ 4. 黑名单机制(用于注销 / 撤销)
- 👉 添加黑名单
- 👉 在 JWT 策略中校验黑名单
- ✅ 5. 登出接口:清除 refresh_token + 加入 access_token 黑名单
- ✅ .env 示例
- ✅ 补充安全建议
前言
涉及到实际线上安全应急策略。在处理 JWT Token 泄露 事件时的标准应对方案,分为【预防】和【应急响应】两部分讲解。
✅ 一、预防措施(防泄露)
措施 | 说明 |
---|---|
1. 设置合理的过期时间 | 通常设置 access_token 有效期为 15分钟~1小时,使用 refresh_token 进行续期 |
2. 使用 HttpOnly Cookie 存储 | 将 token 存在 HttpOnly 、Secure Cookie,避免被 JS 获取 |
3. 绑定设备或 IP | 在 Token 中绑定用户的设备指纹 / IP,泄露后在其他环境验证失败 |
4. 使用 RSA 非对称签名(JWT RS256) | 避免密钥泄露后伪造签名 |
5. 限制高敏感接口访问 | 例如:只允许白名单设备访问后台管理接口 |
6. 检测 token reuse | 多端同 token 出现可疑行为时,自动封禁并记录日志 |
🚨 二、应急响应机制(发现已泄露)
🔒 1. 启用 Token 黑名单机制
JWT 的默认机制是“无状态”,不易手动失效,但大厂都会通过下面方式实现「黑名单」:
- 在数据库或 Redis 中维护一个
revoked_token_list
; - 每次请求都校验 JWT 是否在黑名单中;
- 一旦检测泄露(如同一 token 被不同 IP 使用),就添加进黑名单。
// Redis 中记录已禁用 token ID(jti)
await redis.set(`blacklist:${jti}`, 'revoked', { EX: tokenTTL });
🔁 2. 启用 Refresh Token 机制 + 旋转令牌
access_token
只有 15 分钟有效期refresh_token
有效期 7~30 天,保存在 HttpOnly Cookie 中- 每次 refresh 时颁发新的 access_token + refresh_token(旧的 refresh_token 作废)
泄露后攻击者无法长期使用,因为旧 token 被撤销。
📍 3. 强制下线机制
当用户点击“退出登录”或检测异地登录时:
- 在服务端废弃该 Token(加入黑名单)
- 清空 Redis 中该用户的 session 或 token 映射
- 通知所有前端客户端强制退出(WS / SSE 通知)
🛡️ 4. 异常行为检测 + 风控联动
检测如下行为触发封禁或风控:
行为 | 应对 |
---|---|
同一个 token 突然从多个 IP 访问 | 加入黑名单、强制登出 |
同 IP 请求频率异常 | 加验证码 / 限流 / 风控 |
后台管理接口被频繁尝试 | 触发安全告警 |
🔁 5. 轮换密钥 / Key Rotation
泄露风险发生后:
- 更新 JWT 签名密钥(HMAC / RSA 私钥)
- 使用
kid
(Key ID)字段支持旧 token 回溯校验 - Gradually revoke tokens signed with old keys
🧩 推荐 Token 架构模式(实践)
名称 | 描述 |
---|---|
access_token | 有效期短,存在客户端,访问 API |
refresh_token | 有效期长,存在 HttpOnly Cookie,用于续签 access_token |
jti 字段 | JWT ID,唯一标识某个 Token,配合黑名单机制使用 |
device_id 字段 | 标记发放 token 的设备,用于校验来源 |
Redis 存储 | 记录用户 token 状态、是否登出、是否被撤销 |
✅ 实践建议总结
类型 | 推荐做法 |
---|---|
储存 | ✅ 放 HttpOnly Cookie,而不是 localStorage |
加密 | ✅ 使用 RS256,私钥签名,公钥验证 |
续签 | ✅ 实现 refresh_token 机制,支持滑动续期 |
风控 | ✅ 实现黑名单、IP+设备检测、异地登录风控 |
运维 | ✅ 支持密钥轮换、封禁通知、日志追溯 |
基于 NestJS 实现 Refresh Token + 黑名单机制的方案
📦 技术栈概览
@nestjs/jwt
– 用于签发 access_token 和 refresh_tokenbcrypt
– 密码加密Redis
– 存储黑名单和 refresh_token 状态Prisma
– 用户存储Passport
– 认证中间件
🧠 架构设计核心思想
Token 类型 | 生命周期 | 存储方式 | 用途 |
---|---|---|---|
access_token | 15分钟 | Authorization header | 访问受保护接口 |
refresh_token | 7-30天 | HttpOnly Cookie | 续签 access_token |
黑名单(Blacklist) | access_token.jti 被吊销时记录在 Redis | 拒绝已撤销 token |
✅ 1. Token 签发逻辑(AuthService)
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';async generateTokens(user: User) {const jti = randomUUID(); // 每个 token 一个唯一 IDconst payload = { sub: user.id, email: user.email, jti };const accessToken = this.jwtService.sign(payload, {secret: process.env.JWT_SECRET,expiresIn: '15m',});const refreshToken = this.jwtService.sign(payload, {secret: process.env.REFRESH_TOKEN_SECRET,expiresIn: '7d',});// 存储 refreshToken 状态到 Redis(可选)await this.redis.set(`refresh:${user.id}:${jti}`, 'valid', 'EX', 7 * 86400);return { accessToken, refreshToken };
}
✅ 2. 登录 & 返回 Refresh Token(设置 HttpOnly Cookie)
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {const user = await this.authService.validateUser(dto.email, dto.password);if (!user) throw new UnauthorizedException();const { accessToken, refreshToken } = await this.authService.generateTokens(user);res.cookie('refresh_token', refreshToken, {httpOnly: true,secure: true,sameSite: 'lax',maxAge: 7 * 24 * 60 * 60 * 1000,});return { access_token: accessToken };
}
✅ 3. 刷新接口:/auth/refresh
@Post('refresh')
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {const token = req.cookies?.refresh_token;if (!token) throw new UnauthorizedException();try {const payload = this.jwtService.verify(token, {secret: process.env.REFRESH_TOKEN_SECRET,});// 可选:校验 Redis 是否存在const tokenStatus = await this.redis.get(`refresh:${payload.sub}:${payload.jti}`);if (tokenStatus !== 'valid') throw new UnauthorizedException();// 签发新的 tokenconst user = await this.prisma.user.findUnique({ where: { id: payload.sub } });const { accessToken, refreshToken } = await this.generateTokens(user);// 返回新的 refresh_tokenres.cookie('refresh_token', refreshToken, {httpOnly: true,secure: true,sameSite: 'lax',maxAge: 7 * 24 * 60 * 60 * 1000,});return { access_token: accessToken };} catch (e) {throw new UnauthorizedException();}
}
✅ 4. 黑名单机制(用于注销 / 撤销)
👉 添加黑名单
async revokeAccessToken(jti: string, exp: number) {const ttl = exp - Math.floor(Date.now() / 1000); // 秒await this.redis.set(`blacklist:${jti}`, 'revoked', 'EX', ttl);
}
👉 在 JWT 策略中校验黑名单
async validate(payload: any) {const isBlacklisted = await this.redis.get(`blacklist:${payload.jti}`);if (isBlacklisted) {throw new UnauthorizedException('Token 已被吊销');}return { userId: payload.sub, email: payload.email };
}
✅ 5. 登出接口:清除 refresh_token + 加入 access_token 黑名单
@Post('logout')
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {const token = req.headers.authorization?.split(' ')[1];const payload = this.jwtService.decode(token) as any;await this.authService.revokeAccessToken(payload.jti, payload.exp);await this.redis.del(`refresh:${payload.sub}:${payload.jti}`); // 禁用 refresh_tokenres.clearCookie('refresh_token');return { message: 'Logout successful' };
}
✅ .env 示例
JWT_SECRET=access_token_secret
REFRESH_TOKEN_SECRET=refresh_token_secret
✅ 补充安全建议
项目 | 推荐值 |
---|---|
access_token 生命周期 | 15分钟 |
refresh_token 生命周期 | 7~30天 |
存储方式 | Cookie + Redis |
签名算法 | RS256(可选) |
Token 包含字段 | sub , email , jti , exp |