JWT 身份验证:2026 年安全最佳实践
JSON Web Token 无处不在,但大多数实现都至少存在一个严重的安全缺陷。攻击面比看上去要大:算法混淆攻击、通过 XSS 窃取令牌、缺少过期验证以及不正确的秘密管理是生产系统中最常见的漏洞。正确使用 JWT 并不是调用一个库然后继续前进的问题;而是简单的方法。它需要在每一层进行深思熟虑的决策。
本指南涵盖了完整的 JWT 安全生命周期——从签名算法选择和令牌结构到存储、轮换、撤销和现实世界的 NestJS 实现模式——因此您可以构建真正抵御攻击的身份验证。
要点
- 分布式系统始终使用RS256(非对称);仅当 API 服务器既是发行者又是验证者时才使用 HS256
- 将令牌存储在 HttpOnly、Secure、SameSite=Lax cookie 中 — 切勿存储在 localStorage 或 sessionStorage 中
- 始终验证
exp、iss、aud和alg声明 - 永远不要信任未签名的none算法- 实施刷新令牌轮换:每次刷新都会发出一个新的令牌对并使旧的刷新令牌失效
- 保持访问令牌 TTL 较短(15 分钟);使用存储在数据库中的不透明刷新令牌
- 切勿在 JWT 有效负载中存储敏感数据(密码、SSN、支付信息)——有效负载是 base64,未加密
- 通过数据库中的 Redis 拒绝列表或版本计数器实现令牌撤销
- 记录所有令牌发行和刷新事件以进行安全审计跟踪
JWT 结构和声明
JWT 具有三部分:标头、有效负载和签名,以点分隔并进行 base64url 编码。
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc0MjM0NTYwMCwiZXhwIjoxNzQyMzQ2NTAwLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIn0.
[signature]
解码后的有效负载:
{
"sub": "user_123",
"email": "[email protected]",
"role": "admin",
"iat": 1742345600,
"exp": 1742346500,
"iss": "https://api.example.com",
"aud": "https://app.example.com"
}
生产所需的声明:
sub— 唯一的用户标识符(不要单独发送电子邮件 — 电子邮件会发生变化)exp— 到期时间戳(始终需要)iat— 发布时间戳(检测时钟偏差)iss— 发行人 URL(针对您预期的发行人进行验证)aud— 受众(验证以防止跨服务重复使用令牌)jti— JWT ID(每个令牌唯一,支持精确撤销)
RS256 与 HS256:使用哪种算法
这是 JWT 配置中最有影响力的安全决策。
HS256 (HMAC-SHA256) — 对称
// Both signing and verifying require the same secret
const token = jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
const verified = jwt.verify(token, process.env.JWT_SECRET);
仅在以下情况下使用:签署令牌的服务与验证令牌的服务相同。 HS256 对于整体 API 来说很好,但在微服务中很危险——任何可以验证令牌的服务也可以创建它们。
RS256 (RSA-SHA256) — 非对称
// Sign with private key (only the auth server holds this)
const privateKey = fs.readFileSync('/secrets/jwt-private.pem');
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', keyid: 'key-2026-01' });
// Verify with public key (any service can do this safely)
const publicKey = fs.readFileSync('/secrets/jwt-public.pem');
const verified = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // NEVER omit this — prevents algorithm confusion
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
生成生产 RSA 密钥对:
# Generate 4096-bit RSA private key
openssl genrsa -out jwt-private.pem 4096
# Extract public key
openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem
密钥轮换:在标头中使用 keyid (kid)。在 /.well-known/jwks.json 发布当前公钥。服务缓存 JWKS 并在未知的 kid 上获取。
HttpOnly Cookie 存储
这是没有商量余地的。将令牌存储在 localStorage 或 sessionStorage 中使得页面上运行的任何 JavaScript 都可以访问它们 - 包括来自 XSS 攻击的注入脚本。
// NestJS: set HttpOnly cookie after authentication
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const { accessToken, refreshToken } = await this.authService.login(dto);
const cookieBase = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
domain: process.env.COOKIE_DOMAIN, // '.example.com' for subdomain sharing
};
res.cookie('access_token', accessToken, {
...cookieBase,
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refresh_token', refreshToken, {
...cookieBase,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: '/auth/refresh', // Scope refresh token to only the refresh endpoint
});
return { message: 'Login successful' };
}
// Extract token from cookie in JWT strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req) => req?.cookies?.access_token, // Cookie first
ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer fallback for API clients
]),
secretOrKey: configService.get('JWT_PUBLIC_KEY'),
algorithms: ['RS256'],
issuer: configService.get('JWT_ISSUER'),
audience: configService.get('JWT_AUDIENCE'),
});
}
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
// Always check token version against database
const user = await this.usersService.findById(payload.sub);
if (!user || user.tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException('Token invalidated');
}
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
刷新令牌轮换
切勿颁发长期访问令牌。相反,应保持访问令牌的生命周期较短,并在每次使用时轮换刷新令牌。
// auth.service.ts
@Injectable()
export class AuthService {
async refreshTokens(refreshToken: string, ipAddress: string) {
// 1. Look up the refresh token in the database
const storedToken = await this.db.query.refreshTokens.findFirst({
where: and(
eq(refreshTokens.token, this.hashToken(refreshToken)),
eq(refreshTokens.revoked, false),
gt(refreshTokens.expiresAt, new Date())
),
with: { user: true },
});
if (!storedToken) {
// Possible reuse attack — revoke all tokens for this user
if (storedToken?.userId) {
await this.revokeAllUserTokens(storedToken.userId);
await this.alertService.send({
message: `Refresh token reuse detected for user ${storedToken.userId}`,
severity: 'high',
});
}
throw new UnauthorizedException('Invalid refresh token');
}
// 2. Revoke the used refresh token (rotation)
await this.db
.update(refreshTokens)
.set({ revoked: true, revokedAt: new Date(), revokedByIp: ipAddress })
.where(eq(refreshTokens.id, storedToken.id));
// 3. Issue new token pair
const newAccessToken = this.issueAccessToken(storedToken.user);
const newRefreshToken = await this.issueRefreshToken(
storedToken.user.id,
ipAddress,
storedToken.family // Track token families for reuse detection
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}
刷新令牌的数据库架构:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash of the actual token
family UUID NOT NULL, -- Token family for reuse detection
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN DEFAULT false,
revoked_at TIMESTAMPTZ,
revoked_by_ip INET,
created_at TIMESTAMPTZ DEFAULT now(),
created_by_ip INET
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
令牌撤销策略
JWT 在设计上是无状态的——一旦发布,如果没有额外的基础设施,你就无法“收回它们”。以下是按权衡排序的三种方法:
1. 短 TTL(15 分钟)
最简单的撤销策略:访问令牌过期得很快,因此很少需要撤销它们。与数据库中的立即刷新令牌撤销配对。
2. 令牌版本计数器
在用户表中存储 tokenVersion 。增加它以使所有现有令牌无效:
// Increment version to logout all sessions
await this.db
.update(users)
.set({ tokenVersion: sql`token_version + 1` })
.where(eq(users.id, userId));
// In JWT payload
const payload = {
sub: user.id,
tokenVersion: user.tokenVersion, // embedded at sign time
};
// In JwtStrategy.validate()
if (user.tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException();
}
每个请求需要一次数据库查找 — 对于大多数应用程序来说是可以接受的。
3.Redis 拒绝名单
对于无需数据库查找的立即撤销:
// Revoke specific token by JTI
async revokeToken(jti: string, expiresIn: number): Promise<void> {
const key = `token:revoked:${jti}`;
await this.redis.set(key, '1', 'EX', expiresIn);
}
// Check in JWT strategy before accepting
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
const revoked = await this.redis.get(`token:revoked:${payload.jti}`);
if (revoked) {
throw new UnauthorizedException('Token revoked');
}
return this.buildUser(payload);
}
权衡:每个请求进行一次 Redis 查找,但 Redis 的时间复杂度为 O(1) 且亚毫秒级。对于高安全性端点来说是可接受的。
常见漏洞和缓解措施
算法混淆攻击
alg: "none" 攻击:攻击者剥离签名并将 alg 设置为 none,然后提交篡改的有效负载。接受未签名令牌的库将接受任何有效负载。
// WRONG — never do this
jwt.verify(token, secret); // Accepts alg:none if library allows it
// CORRECT — always specify algorithms explicitly
jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Whitelist only what you use
});
JWT 标头注入 (jwk/jku)
攻击者使用指向自己的密钥服务器的 jku (JWKS URL)或 jwk (内联密钥)标头制作 JWT,然后使用自己的私钥进行签名。易受攻击的验证者获取攻击者的密钥并接受令牌。
// WRONG — never fetch keys from the token header
const jwksUri = decodedHeader.jku; // Attacker-controlled!
// CORRECT — always use a pinned, config-driven JWKS URI
const jwksClient = createRemoteJWKSet(new URL(configService.get('JWKS_URI')));
HS256 的弱秘密
HS256 的 32 字符 ASCII 密钥大约有 190 位熵——这还不够。使用来自加密安全随机源的至少 256 位:
# Generate a strong HS256 secret
node -e "console.log(require('crypto').randomBytes(64).toString('base64url'))"
NestJS JWT 模块配置
// auth.module.ts
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
privateKey: config.get('JWT_PRIVATE_KEY'),
publicKey: config.get('JWT_PUBLIC_KEY'),
signOptions: {
algorithm: 'RS256',
expiresIn: '15m',
issuer: config.get('JWT_ISSUER'),
audience: config.get('JWT_AUDIENCE'),
},
verifyOptions: {
algorithms: ['RS256'],
issuer: config.get('JWT_ISSUER'),
audience: config.get('JWT_AUDIENCE'),
},
}),
}),
],
})
export class AuthModule {}
用于公钥分发的 JWKS 端点
// jwks.controller.ts
@Controller('.well-known')
export class JwksController {
@Get('jwks.json')
@Public()
async getJwks() {
const publicKeyPem = this.configService.get('JWT_PUBLIC_KEY');
// Convert PEM to JWK format using the 'jose' library
const publicKey = await importSPKI(publicKeyPem, 'RS256');
const jwk = await exportJWK(publicKey);
return {
keys: [
{
...jwk,
use: 'sig',
alg: 'RS256',
kid: this.configService.get('JWT_KEY_ID'), // e.g., 'key-2026-01'
},
],
};
}
}
积极缓存此端点——公钥很少改变。设置 Cache-Control: public, max-age=3600。
安全检查表
在将 JWT 身份验证部署到生产环境之前,请验证:
- 算法在签名和验证调用中显式设置为
RS256或HS256 -
exp、iss、aud对每个请求进行验证 - 访问令牌 TTL ≤ 15 分钟
- 令牌存储在 HttpOnly、Secure、SameSite=Lax cookies 中
- 实施刷新令牌轮换 — 每次使用时都会撤销旧令牌
- 刷新数据库中存储为 SHA256 哈希值的令牌(非明文)
-
jti声明添加用于有针对性的撤销功能 - Redis 拒绝列表检查高安全性端点
- JWKS端点为分布式服务发布
- 私钥存储在机密管理器(AWS Secrets Manager、Vault)中
- 密钥轮换程序已记录并经过测试
- 记录所有代币发行和失效事件
常见问题
在不验证签名的情况下解码 JWT 是否安全?
未经验证的解码对于读取有效负载来说是安全的,但永远不要信任授权的内容。在处理索赔之前务必验证签名。大多数库中的 jwt.decode() 函数会跳过验证 - 仅将其用于诊断或在选择正确的密钥进行验证之前从标头读取 kid 。
我应该为浏览器应用程序使用 cookie 或授权标头吗?
用于浏览器应用程序的 HttpOnly cookie、用于本机移动应用程序的授权标头和服务器到服务器 API 调用。 Cookie 不受 XSS 渗透影响(JavaScript 无法读取 HttpOnly cookie)。移动应用程序无法有效使用 cookie 并使用存储在设备安全密钥库中的不记名令牌。
如何在前端处理令牌过期?
拦截 401 响应并在重试原始请求之前尝试静默刷新。在 React 中,使用 Axios 或 fetch 拦截器。如果刷新也失败(过期或撤销),则重定向到登录。保持单一的动态刷新承诺,以防止并行刷新风暴。
访问令牌和刷新令牌有什么区别?
访问令牌是短暂的(15 分钟)、无状态,并在每个 API 请求上使用公钥或共享密钥进行验证。刷新令牌具有较长的生命周期(7-30 天)、不透明(随机字符串,而不是 JWT),并且存储在服务器端数据库中。刷新令牌端点是唯一使用刷新令牌的地方 - 使用狭窄的 cookie 路径来限定它们的范围。
我可以在 JWT 负载中存储用户角色吗?
是的,但请注意,令牌中编码的角色会被缓存,直到令牌过期。如果您撤销用户的管理员角色,他们会保留该角色,直到其当前访问令牌过期(最多 15 分钟)。对于高安全性角色更改,还可以将用户添加到 Redis 拒绝列表或增加其令牌版本以强制立即重新进行身份验证。
如何使用 JWT 实现“记住我”?
当用户选中“记住我”而不是标准的 30 天时,发出寿命较长的刷新令牌(90 天)。在刷新令牌数据库行中存储 persistent 标志,以便您可以在用户的安全设置中单独显示和撤销持久会话。切勿延长访问令牌 TTL — 这违背了目的。
后续步骤
正确完成 JWT 身份验证是每个安全 Web 应用程序的基础。从 RS256 签名到 HttpOnly cookie 存储、刷新令牌轮换和撤销策略,本指南中的模式可保护您的用户免受最常见的身份验证攻击。
ECOSIRE 在我们所有的后端项目中实现了经过考验的身份验证架构,包括 OIDC 与 Authentik 集成、HttpOnly cookie 流和 Redis 支持的令牌管理。 探索我们以安全为中心的开发服务,了解我们如何强化您的身份验证层。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
Authentik OIDC/SSO: Complete Integration Guide
Complete Authentik OIDC and SSO integration guide: OAuth2 provider setup, Next.js callback handling, NestJS JWT validation, user provisioning, and production configuration.
Cybersecurity Trends 2026-2027: Zero Trust, AI Threats, and Defense
The definitive guide to cybersecurity trends for 2026-2027—AI-powered attacks, zero trust implementation, supply chain security, and building resilient security programs.