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 TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
OpenClaw 大规模成本优化和代币效率
OpenClaw 令牌成本优化:提示缓存、模型路由、响应缓存、批处理 API 和生产代理的每租户成本护栏。
OpenClaw 安全模型、数据驻留、SOC 2 和 ISO 27001
OpenClaw 安全架构:租户隔离、加密、秘密管理、审计日志、数据驻留、SOC 2、ISO 27001、GDPR、HIPAA 适应性。
Power BI 行级安全性:动态与静态模式
Power BI RLS 深入探讨:静态角色与动态角色、USERPRINCIPALNAME 模式、安全表、管理器层次结构、RLS 测试和用于 SaaS 的嵌入式 RLS。