JWT 因其无状态、跨服务易用被广泛采用,但配置不严谨会成攻击重灾区。现代系统常需第三方登录或企业 SSO,选对联邦协议也是架构师必修课。
JWT 安全实现要点
绝对不要使用对称密钥 HMAC 直接在前端签发(密钥会泄漏),生产用 RS256/ES256 非对称,公钥公开验证。严格白名单允许算法、用 timingSafeEqual 做 iss/aud 对比防时序攻击,jti 黑名单支持吊销。
import { createSign, createVerify, randomBytes, timingSafeEqual } from "node:crypto";
export interface JwtConfig {
algorithm: "RS256";
privateKeyPem: string;
publicKeyPem: string;
issuer: string;
audience: string;
accessTokenTtlSec: number;
allowedAlgos: readonly string[];
}
export interface TokenPayload {
sub: string; scopes: readonly string[]; jti: string;
exp: number; nbf: number; iat: number; iss: string; aud: string;
}
const B64 = {
enc: (b: Buffer) => b.toString("base64url"),
dec: (s: string) => Buffer.from(s, "base64url"),
};
const DENIED = new Set<string>();
export function createJwt(cfg: JwtConfig, claims: Partial<TokenPayload> & { sub: string; scopes: readonly string[] }): string {
const now = Math.floor(Date.now() / 1000);
const header = { alg: cfg.algorithm, typ: "JWT", kid: "k1" };
const payload: TokenPayload = {
sub: claims.sub, scopes: claims.scopes,
jti: randomBytes(16).toString("hex"),
iat: now, nbf: now, exp: now + cfg.accessTokenTtlSec,
iss: cfg.issuer, aud: cfg.audience,
};
const input = `${B64.enc(Buffer.from(JSON.stringify(header)))}.${B64.enc(Buffer.from(JSON.stringify(payload)))}`;
const s = createSign("RSA-SHA256"); s.update(input);
return `${input}.${B64.enc(s.sign(cfg.privateKeyPem))}`;
}
export function verifyJwt(cfg: JwtConfig, token: string): TokenPayload {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("malformed");
const [hB64, pB64, sB64] = parts;
const header = JSON.parse(B64.dec(hB64).toString());
if (!cfg.allowedAlgos.includes(header.alg) || header.alg === "none") throw new Error("alg not allowed");
const ver = createVerify("RSA-SHA256"); ver.update(`${hB64}.${pB64}`);
if (!ver.verify(cfg.publicKeyPem, B64.dec(sB64))) throw new Error("signature mismatch");
const p = JSON.parse(B64.dec(pB64).toString()) as TokenPayload;
if (!timingSafeEqual(Buffer.from(p.iss), Buffer.from(cfg.issuer))) throw new Error("iss");
if (p.aud !== cfg.audience) throw new Error("aud");
const now = Math.floor(Date.now() / 1000);
if (p.exp < now) throw new Error("expired");
if (p.nbf > now) throw new Error("not yet valid");
if (DENIED.has(p.jti)) throw new Error("revoked");
return p;
}
OAuth2 / OIDC / SAML 对比
OAuth2 是授权框架不是认证协议,OpenID Connect 在其之上加了 id_token 才是登录。SAML 2.0 是传统企业联邦协议,XML 格式兼容性强但移动端体验差。
| 特性 | OAuth2 + PKCE | OIDC (基于OAuth2) | SAML 2.0 |
|---|---|---|---|
| 本质 | 授权(delegation) | 认证(authentication) | 认证+授权联邦 |
| 适用 | 移动端/SPA | Web/移动/API | 企业内部SSO |
| Token | access_token | access_token + id_token(JWT) | SAML Assertion(XML) |
| 安全性 | 需PKCE防code注入 | 原生支持nonce | 签名XML流程复杂 |
| 移动端体验 | 好(App转场) | 好 | 差(重定向多) |
| 集成复杂度 | 中 | 低 | 高(双方元数据) |
最佳实践
JWT 存敏感信息:短 TTL(5-15min) + refresh_token 轮换。SPA 用 HttpOnly Cookie 承载 refresh_token 避免 XSS。第三方登录场景统一用 OIDC。