JWT Authentication: Security Best Practices in 2026

Secure your APIs with JWT best practices: RS256 vs HS256, HttpOnly cookies, token rotation, refresh patterns, and common vulnerabilities to avoid in 2026.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分钟阅读1.3k 字数|

JWT 身份验证:2026 年安全最佳实践

JSON Web Token 无处不在,但大多数实现都至少存在一个严重的安全缺陷。攻击面比看上去要大:算法混淆攻击、通过 XSS 窃取令牌、缺少过期验证以及不正确的秘密管理是生产系统中最常见的漏洞。正确使用 JWT 并不是调用一个库然后继续前进的问题;而是简单的方法。它需要在每一层进行深思熟虑的决策。

本指南涵盖了完整的 JWT 安全生命周期——从签名算法选择和令牌结构到存储、轮换、撤销和现实世界的 NestJS 实现模式——因此您可以构建真正抵御攻击的身份验证。

要点

  • 分布式系统始终使用RS256(非对称);仅当 API 服务器既是发行者又是验证者时才使用 HS256
  • 将令牌存储在 HttpOnly、Secure、SameSite=Lax cookie 中 — 切勿存储在 localStorage 或 sessionStorage 中
  • 始终验证 expissaudalg 声明 - 永远不要信任未签名的 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 上获取。


这是没有商量余地的。将令牌存储在 localStoragesessionStorage 中使得页面上运行的任何 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 身份验证部署到生产环境之前,请验证:

  • 算法在签名和验证调用中显式设置为 RS256HS256
  • expissaud 对每个请求进行验证
  • 访问令牌 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 支持的令牌管理。 探索我们以安全为中心的开发服务,了解我们如何强化您的身份验证层。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天