关于 secure-auth
这是一份涵盖密码、会话、JWT 和通行密钥的生产认证指南,提供方案选择与安全要求,并要求团队在采用示例前研究最新标准和威胁。
主要功能
- 01会话与 JWT 选择指南
- 02通行密钥优先架构建议
- 03密码哈希与策略规则
- 04会话和刷新令牌模式
- 05JWT 声明验证要求
适用场景
- 01为 Web 应用选择认证方式
- 02实现密码存储与策略
- 03在密码登录旁加入通行密钥
- 04验证 JWT 和刷新令牌
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jamditis/ claude-skills-journalism --skill secure-auth安装后在 Claude Code 中输入/secure-auth确认已加载。
附带文件agents/openai.yaml
详细介绍
安全身份验证
步骤 0:研究当前安全态势(首先执行此步骤)
安全知识的半衰期为 6–12 个月。以下操作指南最后一次验证于 2026-05-08;你阅读时,内容可能已经过时。在应用本技能中的任何模式之前,应针对正在实现的身份验证原语(密码、会话、JWT、OAuth、MFA、通行密钥)开展多角度研究,确保根据当前权威来源解读操作指南,而不是依赖本文的快照。
默认启用,可记录理由后跳过
默认执行以下 4 个角度的研究。仅当以下条件全部满足时才可跳过:
- (a) 你在当前会话的最近 4 小时内,针对同一身份验证原语运行过此技能;
- (b) 此前的研究未发现与正在实现的身份验证原语(密码、会话、JWT、OAuth、MFA、通行密钥)相关的紧急安全公告;
- (c) 你在回复中记录一行
Research skipped because <reason>。
“我觉得自己懂”/“要赶进度”/“用户希望尽快完成”/“我已经很熟悉”都不是有效的跳过理由。本段的全部目的,就是提醒未来的你:在核查当前情况之前,不要轻信此技能正文中的默认设置。
并行分派 4 个子 Agent
每个子 Agent 最多返回 300 词的要点,并附上引用。通过一条消息分派全部 4 个子 Agent,以便它们并行运行。
角度 1,权威标准。 NIST/OWASP/IETF(RFC 和 Internet-Draft)/W3C/CISA 是否在过去 6–12 个月发布了与正在实现的身份验证原语(密码、会话、JWT、OAuth、MFA、通行密钥)相关的新内容?查找:规范定稿、弃用公告、替代规范、RFC 发布、草案修订、NIST SP 更新、OWASP 项目版本升级。引用文档编号和发布日期。
角度 2,活跃攻击。 针对正在实现的身份验证原语(密码、会话、JWT、OAuth、MFA、通行密钥),目前有哪些漏洞正遭到积极利用?查询:CISA 已知遭利用漏洞(KEV)目录(筛选最近 6–12 个月的条目)、近期高 CVSS 评分或遭到现实攻击的 CVE/GHSA 条目、数据泄露事后分析和事件报告(CSRB、供应商根因分析、安全供应商研究)。指出近期 KEV 收录中占主导地位的 CWE 模式。引用 CVE 编号和公告 URL。
角度 3,工具和库的现状。 本技能推荐的库是否仍是最新版本?相关包注册表(npm/PyPI/RubyGems/crates.io)中的最新主要版本是什么?是否有库已弃用、被替代或合并到其他项目?是否有库更改了安全默认设置?查询当前版本:registry.npmjs.org、pypi.org、rubygems.org、crates.io、pkg.go.dev。引用包名、版本和发布日期。
角度 4,实践者讨论。 过去 6 个月,实践者和安全团队都在讨论什么?查询:OWASP 备忘单系列(最后修改日期很重要)、GitHub Security Lab 文章、供应商安全博客(Cloudflare、Fastly、Snyk、Datadog、Wiz、GitGuardian)、会议演讲(Black Hat、DEF CON、OWASP Global AppSec、USENIX Security)、SANS ISC、Krebs、近期 OWASP 项目重新发布的内容。指出正在采用的模式和受到批评的反模式。引用文章 URL、作者和日期。
应用方案前先综合信息
4 项搜索结果返回后,先写 1 段“截至 <today's date>,正在实现的身份验证机制(密码、会话、JWT、OAuth、MFA、通行密钥)的当前状态”,并列出:
- 当前规范的最高要求(规范指出 2026 年默认值应是什么)。
- 过去 6-12 个月内,与正在实现的身份验证机制(密码、会话、JWT、OAuth、MFA、通行密钥)相关的 1-2 项活跃威胁。
- 工具链的变化(已弃用的库、框架中的新默认值、合并或替换的软件包)。
- 最近的速查表 / 博客更新所体现的从业者共识变化。
如果综合信息发现此技能正文中的方案已过时(例如,某项规范在 2026-05-08 后定稿、某个库现已弃用、某个默认值已更改),请在回复中明确指出,并以综合信息为准,覆盖与之冲突的技能正文。综合信息优先。技能正文只是参考,不是教条。
无法运行子 Agent 时
如果当前运行环境不支持子 Agent,则在行内按相同方式进行:依次执行 4 次有针对性的搜索(搜索网络标准、查询 KEV 目录、检查软件包注册表版本、查看近期速查表差异)。得出同样的 1 段综合信息。成本会上升,但保护措施不变。
可用于生产环境的身份验证模式。这些不是最简单的实现,而是不容易让你惹上官司的实现。
身份验证架构决策
2020 年代初期的“会话还是 JWT”框架已不再是唯一的考量维度。到了 2026 年,问题更接近于“通行密钥加短期绑定令牌”还是“会话 Cookie”。应根据部署形态选择,而非照搬教程中的做法。
会话
适用场景:
- 服务端渲染的应用
- 需要立即注销 / 撤销
- 单一域名
- 更容易正确实现
JWT(搭配刷新令牌)
适用场景:
- 多个服务需要验证身份
- 优先采用无状态验证(需有撤销策略)
- 移动应用加 API
- 第三方集成
- 高价值 API 可从发送方约束令牌中获益(DPoP,依据 RFC 9449;mTLS,依据 RFC 8705)
通行密钥优先
在以下情况下,将通行密钥(WebAuthn / FIDO2)用作主认证因子:
- 用户代理支持 WebAuthn(当前所有 Chromium、Firefox、Safari 和主流移动浏览器均支持)
- 可以在过渡期间与密码并行使用(首次登录后提供通行密钥注册选项;在用户安装期间保留密码作为备用方式)
- 需要防范钓鱼(NIST AAL3、政府、金融、医疗)
通行密钥优先的做法反映了 2026 年的共识:WebAuthn L3 于 2026-01-13 达到 W3C 候选推荐快照(https://www.w3.org/TR/webauthn-3/),CTAP 2.3 于 2026-02-26 成为 FIDO Alliance 提议标准。请参阅下文“通行密钥 / WebAuthn”部分。
常见错误: 因为教程这么做就使用 JWT,随后将其存储在 localStorage 中(易受 XSS 攻击),且没有撤销策略。刷新令牌重用检测和完整的令牌验证(签发者、受众、作用域、签名密钥租户)在 2026 年都是必不可少的,参见 Storm-0558 案例的教训。
密码存储
这是本技能中密码哈希的唯一事实来源。下文的会话和 JWT 示例均采用这些默认值。
默认值:argon2id
OWASP《密码存储速查表》(最后更新于 2026-05-07,https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)列出了 5 种等效的 argon2id 配置。根据服务器的内存预算选择即可;它们的工作因子经过校准,彼此相近:
- m=47104 KiB(46 MiB),t=1,p=1
- m=19456 KiB(19 MiB),t=2,p=1
- m=12288 KiB(12 MiB),t=3,p=1
- m=9216 KiB(9 MiB),t=4,p=1
- m=7168 KiB(7 MiB),t=5,p=1
// Node, argon2 package (current 0.44.0; pre-1.0, pin by minor)
const argon2 = require('argon2');
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // KiB, one of the 5 OWASP-equivalent profiles
timeCost: 2,
parallelism: 1
});
// Verify
const valid = await argon2.verify(hash, password);
# Python, argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
memory_cost=19456, # KiB
time_cost=2,
parallelism=1,
)
hashed = ph.hash(password)
try:
ph.verify(hashed, password)
except VerifyMismatchError:
# invalid password
pass
RFC 9106(https://datatracker.ietf.org/doc/rfc9106/)定义了更强的“首选推荐”配置(t=1,p=4,m=2 GiB),适用于具备相应可用内存的服务器环境;OWASP 的 5 种配置是实用的最低标准。
备选:bcrypt
仍可接受。OWASP 表示成本至少为 10,“在性能允许的范围内尽可能高”。当前代码使用成本 12,这没问题,只需注意最低要求已提高。
// Node, bcrypt (current 6.0.0)
const bcrypt = require('bcrypt');
// bcrypt has a 72-byte input limit. Pre-hash with SHA-256
// when accepting longer passphrases, OR reject inputs over 72 bytes.
const crypto = require('crypto');
function safeBcryptInput(password) {
const bytes = Buffer.byteLength(password, 'utf8');
if (bytes > 72) {
// Pre-hash to a fixed 32-byte digest, base64-encoded (44 ASCII bytes)
return crypto.createHash('sha256').update(password).digest('base64');
}
return password;
}
const hashed = await bcrypt.hash(safeBcryptInput(password), 12);
const ok = await bcrypt.compare(safeBcryptInput(password), hashed);
备选:scrypt
可接受。OWASP 的最低要求是 N=2^17、r=8、p=1。
PBKDF2:仅在要求符合 FIPS-140 时使用
当符合 FIPS-140 是硬性要求时,应使用 PBKDF2。否则优先使用 argon2id。OWASP 的最低要求:600,000 次 PBKDF2-HMAC-SHA256 迭代,或 210,000 次 PBKDF2-HMAC-SHA512 迭代。
绝对不要
- 使用操作系统的 Shell 执行原语将密码拼入命令行;应通过 stdin 或 argv 传递。
- 明文存储。没有例外,不要“暂时这样”,也不要想着“上线前再修复”。
- 在任何代码路径中记录明文密码,包括错误处理程序。
- 使用自定义哈希方案。自行实现是最常见的泄露前提。
密码策略(NIST SP 800-63B-4)
NIST SP 800-63B-4 于 2025-07-31 正式发布(https://csrc.nist.gov/pubs/sp/800/63/b/4/final)。旧版 800-63B 于 2025-08-01 撤回。以下数值具有规范效力,不要偏离。
长度
- 单因素密码:至少 15 个字符。
- 多因素密码(多个因素之一):至少 8 个字符。
- 最大长度:至少 64 个字符(验证方 MUST 允许最多 64 个字符;MAY 允许更长)。
组成规则
- 不得设置组成规则。 NIST 800-63B-4 §5.1.1 明确规定:“验证方和 CSP 不得强制执行其他组成规则”(例如“必须包含大写字母/数字/符号”)。
- 允许 Unicode。 每个 Unicode 码点计为一个字符。
- 允许粘贴。 密码管理器依赖此功能。
轮换
- 不要定期轮换。 不要强制用户“每 90 天更改一次”。仅在有泄露证据时轮换。
黑名单(2026 年强制要求)
验证方 MUST 将候选密码与已知泄露值列表进行比对。使用 HaveIBeenPwned k-anonymity API(https://haveibeenpwned.com/API/v3#PwnedPasswords):提交 SHA-1 哈希的前 5 个字符,获取匹配的后缀;绝不要发送密码本身。
// Check candidate password against HIBP
const crypto = require('crypto');
async function isPwned(password) {
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' }
});
if (!res.ok) {
// Fail closed on availability blip? Up to your threat model.
// Default: don't block registration if HIBP is down; log and proceed.
return false;
}
const body = await res.text();
return body.split('\n').some(line => line.startsWith(suffix));
}
抗钓鱼能力
NIST 800-63B-4 要求 AAL3 具备抗钓鱼能力。仅凭密码永远无法达到 AAL3。如今,大多数面向消费者的应用应以具备抗钓鱼能力的 AAL2 为目标,也就是说使用 WebAuthn(通行密钥)或 PIV/CAC,而不是 TOTP 或 SMS。
基于会话的身份验证
完整的 Express.js 实现
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const argon2 = require('argon2');
const crypto = require('crypto');
const app = express();
// Redis client for session storage
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
// Session configuration
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // At least 32 random bytes
name: 'sessionId', // Don't use default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Rate limiting for auth endpoints
const loginAttempts = new Map();
function checkRateLimit(ip) {
const attempts = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + 900000 };
if (Date.now() > attempts.resetAt) {
attempts.count = 0;
attempts.resetAt = Date.now() + 900000; // 15 minute window
}
if (attempts.count >= 5) {
return false;
}
attempts.count++;
loginAttempts.set(ip, attempts);
return true;
}
// Argon2id parameters, one of the 5 OWASP-equivalent profiles
const ARGON2_OPTS = {
type: argon2.argon2id,
memoryCost: 19456, // KiB
timeCost: 2,
parallelism: 1
};
// Precompute a real argon2id hash for the user-not-found timing-attack defense.
// Must be a valid hash string at the same parameters used for storage so that
// argon2.verify does the full work, a malformed string would short-circuit at
// parse time and reintroduce the timing channel.
let DUMMY_VERIFY_HASH = null;
(async () => {
DUMMY_VERIFY_HASH = await argon2.hash('argon2-timing-defense-init', ARGON2_OPTS);
})();
// Registration
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// NIST 800-63B-4: 15-char minimum for single-factor passwords
if (password.length < 15) {
return res.status(400).json({ error: 'Password must be at least 15 characters' });
}
// Reject up-front pwned passwords (HIBP k-anonymity)
if (await isPwned(password)) {
return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
}
// Check if user exists
const existingUser = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
// Don't reveal if email exists - use same message/timing
return res.status(400).json({ error: 'Registration failed' });
}
// Hash password
const hashedPassword = await argon2.hash(password, ARGON2_OPTS);
// Create user
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
[email.toLowerCase(), hashedPassword]
);
// Create session
req.session.userId = result.rows[0].id;
req.session.createdAt = Date.now();
res.json({ success: true });
});
// Login
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const clientIp = req.ip;
// Rate limiting
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Find user
const result = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
// Timing attack prevention: full-cost verify against a real precomputed hash.
// A malformed hash string would make argon2.verify fail at parse time,
// that's faster than the valid-user path and leaks "user not found" via
// timing. DUMMY_VERIFY_HASH is computed once at module init below so
// this path does the same work as the real verify.
if (DUMMY_VERIFY_HASH) {
await argon2.verify(DUMMY_VERIFY_HASH, password).catch(() => false);
}
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
// Verify password
const isValid = await argon2.verify(user.password_hash, password).catch(() => false);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session to prevent fixation.
// Also regenerate on privilege CHANGE (e.g. role escalation, MFA upgrade), not just login.
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
req.session.createdAt = Date.now();
// Clear rate limit on successful login
loginAttempts.delete(clientIp);
res.json({ success: true });
});
});
// Logout
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('sessionId');
res.json({ success: true });
});
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Optional: Check session age
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: 'Session expired' });
}
next();
}
// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.query(
'SELECT id, email, created_at FROM users WHERE id = $1',
[req.session.userId]
);
res.json(user.rows[0]);
});
JWT 身份验证
完整的令牌验证:Storm-0558 事件的教训
Microsoft 的 Storm-0558 事件(CSRB 审查,https://www.cisa.gov/resources-tools/resources/CSRB-Review-Summer-2023-MEO-Intrusion)追溯发现,OWA 接受了由消费者密钥签名、用于企业邮箱的令牌;令牌验证库跳过了签发方/受众/作用域/签名密钥所属租户检查。每次都要验证每项声明:
iss(签发方),MUST 与预期的签发方字符串匹配aud(受众),MUST 包含你的服务标识符exp、nbf(过期时间/生效时间),MUST 强制执行;拒绝已过期或生效时间在未来的令牌scope,MUST 包含端点所需的作用域- 签名密钥,MUST 属于签发方的租户,而不是“JWKS 端点提供的任意密钥”
包含刷新令牌的完整实现
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Token configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
const ISSUER = 'https://auth.yourapp.com';
const AUDIENCE = 'https://api.yourapp.com';
// Store refresh tokens (use Redis in production).
// Each entry tracks the family chain so reuse can revoke siblings.
const refreshTokens = new Map();
function generateAccessToken(userId, scopes = []) {
return jwt.sign(
{ userId, scope: scopes.join(' '), type: 'access' },
ACCESS_TOKEN_SECRET,
{
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: ISSUER,
audience: AUDIENCE
}
);
}
function generateRefreshToken(userId, familyId = null) {
const tokenId = crypto.randomBytes(32).toString('hex');
const family = familyId || crypto.randomBytes(16).toString('hex');
const token = jwt.sign(
{ userId, tokenId, familyId: family, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{
expiresIn: REFRESH_TOKEN_EXPIRY,
issuer: ISSUER,
audience: AUDIENCE
}
);
refreshTokens.set(tokenId, {
userId,
familyId: family,
createdAt: Date.now(),
used: false,
revoked: false
});
return { token, familyId: family };
}
// Revoke an entire refresh-token family (used on reuse detection).
function revokeFamily(familyId) {
for (const [id, entry] of refreshTokens) {
if (entry.familyId === familyId) {
entry.revoked = true;
}
}
}
// Login - returns both tokens
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// ... validation and password check ...
const accessToken = generateAccessToken(user.id, ['read', 'write']);
const { token: refreshToken } = generateRefreshToken(user.id);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Return access token in response body
res.json({ accessToken });
});
// Refresh endpoint with rotation + reuse detection
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
let decoded;
try {
decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
issuer: ISSUER,
audience: AUDIENCE
});
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const stored = refreshTokens.get(decoded.tokenId);
if (!stored || stored.revoked) {
return res.status(401).json({ error: 'Token revoked' });
}
// REUSE DETECTION: if a previously-rotated refresh token is presented,
// the family is compromised. Revoke every sibling immediately.
if (stored.used) {
revokeFamily(decoded.familyId);
return res.status(401).json({ error: 'Token reuse detected; family revoked' });
}
stored.used = true;
// Rotate: issue a new access + refresh in the same family.
const newAccess = generateAccessToken(decoded.userId, ['read', 'write']);
const { token: newRefresh } = generateRefreshToken(decoded.userId, decoded.familyId);
res.cookie('refreshToken', newRefresh, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccess });
});
// Logout - revoke entire refresh-token family
app.post('/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
issuer: ISSUER,
audience: AUDIENCE
});
revokeFamily(decoded.familyId);
} catch (err) {
// Token invalid, no action needed
}
}
res.clearCookie('refreshToken');
res.json({ success: true });
});
// Auth middleware for protected routes.
//
// Scope of THIS sample: single-issuer private-token deployments using HS256
// (shared secret). It enforces iss / aud / exp / scope / pinned algorithm,
// the claim-level half of the Storm-0558 lesson.
//
// What this sample does NOT cover and you MUST add for multi-issuer / OIDC
// (Azure AD / Auth0 / Cognito / etc.): the signing-key tenancy half. There:
// 1. Use a public-key algorithm (RS256, ES256, EdDSA), not HS256.
// 2. Resolve the issuer's JWKS at the issuer's well-known URL.
// 3. Pin which JWKS each trusted issuer is allowed to use, and reject any
// token whose iss does not match the JWKS that signed it. Storm-0558's
// proximate failure was OWA accepting a consumer-tenant key for an
// enterprise-tenant token because the verifier did NOT enforce this
// issuer-to-key-set binding.
// 4. Select the verifying key by the JWT's `kid`, but only from within
// the pinned key set for that issuer.
function requireAuth(requiredScope) {
return function (req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.substring(7);
let decoded;
try {
decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['HS256'] // pin algorithm; reject 'none' and unexpected algs
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
if (decoded.type !== 'access') {
return res.status(401).json({ error: 'Invalid token type' });
}
if (requiredScope) {
const scopes = (decoded.scope || '').split(' ');
if (!scopes.includes(requiredScope)) {
return res.status(403).json({ error: 'Insufficient scope' });
}
}
req.userId = decoded.userId;
req.scopes = (decoded.scope || '').split(' ');
next();
};
}
// Usage: app.get('/api/admin', requireAuth('admin'), handler)
面向高价值 API 的绑定令牌
Bearer 令牌可能被窃取。对于支付、医疗保健、政府服务,或任何令牌被盗会造成灾难性后果的场景,应使用发送方约束令牌:
- **DPoP(RFC 9449,https://datatracker.ietf.org/doc/rfc9449/)**:客户端使用私钥为每个请求签名;访问令牌绑定到公钥指纹(`cnf.jkt` 声明)。没有密钥,单独的令牌毫无用处。
- **mTLS(RFC 8705,https://datatracker.ietf.org/doc/rfc8705/)**:令牌绑定到 TLS 握手中使用的客户端证书(
cnf.x5t#S256声明)。
两者都会让被盗令牌对攻击者毫无价值。
前端令牌处理
// auth.js - Frontend token management
class AuthManager {
constructor() {
this.accessToken = null;
}
async login(email, password) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important for cookies
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return true;
}
async refreshToken() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
this.accessToken = null;
throw new Error('Session expired');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return accessToken;
}
async fetchWithAuth(url, options = {}) {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
// If token expired, try to refresh and retry
if (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
await this.refreshToken();
// Retry original request
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
}
return response;
}
async logout() {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.accessToken = null;
}
}
export const auth = new AuthManager();
不要将访问令牌存储在 localStorage 中,因为任何 XSS 攻击载荷都能读取它。标准做法是在内存中存储令牌(如上所示),并使用 httpOnly 刷新 Cookie。对于需要在页面刷新后保持登录状态的 SPA,可使用 BroadcastChannel API 在标签页之间共享内存中的令牌,并接受硬刷新会触发一次 /auth/refresh 调用。
Passkeys / WebAuthn
截至 2026-01-13,WebAuthn L3 处于 W3C 候选标准快照阶段(https://www.w3.org/TR/webauthn-3/)。意见征集截止日期为 2026-02-10。CTAP 2.3 于 2026-02-26 成为 FIDO Alliance 提议标准。
推荐的库:
- Node:
@simplewebauthn/server(当前版本为 13.3.0)。配套的浏览器包:@simplewebauthn/browser。 - Python: PyPI 上的
webauthn(当前版本为 2.7.1)。注意:GitHub 仓库名为duo-labs/py_webauthn,但安装命令是pip install webauthn,PyPI 包名是webauthn,而非py_webauthn。
注册流程
// Node, @simplewebauthn/server
const {
generateRegistrationOptions,
verifyRegistrationResponse,
} = require('@simplewebauthn/server');
const RP_ID = 'yourapp.com'; // your effective domain
const RP_NAME = 'YourApp';
const ORIGIN = 'https://yourapp.com';
// 1. Server: build options for the browser
app.post('/auth/passkey/register/options', requireAuth(), async (req, res) => {
const user = await db.query('SELECT id, email, name FROM users WHERE id = $1', [req.userId]);
const existing = await db.query(
'SELECT credential_id, transports FROM passkeys WHERE user_id = $1',
[req.userId]
);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(String(user.rows[0].id)),
userName: user.rows[0].email,
userDisplayName: user.rows[0].name,
attestationType: 'none',
excludeCredentials: existing.rows.map(c => ({
id: c.credential_id,
transports: c.transports || undefined,
})),
authenticatorSelection: {
residentKey: 'preferred', // discoverable credentials enable conditional UI
userVerification: 'preferred',
},
});
// Store challenge for the verify step (Redis with short TTL)
await redisClient.setEx(
`webauthn:reg:${req.userId}`,
300,
options.challenge
);
res.json(options);
});
// 2. Server: verify what the browser returned
app.post('/auth/passkey/register/verify', requireAuth(), async (req, res) => {
const expectedChallenge = await redisClient.get(`webauthn:reg:${req.userId}`);
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false,
});
} catch (err) {
return res.status(400).json({ error: err.message });
}
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'Registration not verified' });
}
const { credential, credentialBackedUp, credentialDeviceType } =
verification.registrationInfo;
await db.query(
`INSERT INTO passkeys
(user_id, credential_id, public_key, counter, transports, device_type, backed_up)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
req.userId,
credential.id,
credential.publicKey,
credential.counter,
req.body.response?.transports || null,
credentialDeviceType,
credentialBackedUp,
]
);
await redisClient.del(`webauthn:reg:${req.userId}`);
res.json({ verified: true });
});
在本地安装并打包浏览器辅助程序。提交锁文件和生成的资源,并在部署期间验证其校验和。如果模块代码像以下示例一样保留为内联代码,请为每个响应生成一个新的 nonce,并发送类似 script-src 'self' 'nonce-{{CSP_NONCE}}' 的 CSP;替换标头和标签中的占位符,且绝不重复使用 nonce。或者,将模块代码移至同源外部文件,并使用不带 nonce 的 script-src 'self'。仅为远程模块图中的第一个导入添加 SRI,无法保证整个模块图的安全。
npm install --save-exact @simplewebauthn/[email protected]
npm install --save-dev --save-exact [email protected]
npm ci
mkdir -p public/vendor
npx esbuild @simplewebauthn/browser --bundle --format=esm --platform=browser \
--outfile=public/vendor/simplewebauthn-browser-13.3.0.mjs
sha256sum public/vendor/simplewebauthn-browser-13.3.0.mjs \
> public/vendor/SHA256SUMS
sha256sum -c public/vendor/SHA256SUMS
<script type="module" nonce="{{CSP_NONCE}}">
import { startRegistration }
from '/vendor/simplewebauthn-browser-13.3.0.mjs';
async function enrollPasskey() {
const optsRes = await fetch('/auth/passkey/register/options', { method: 'POST', credentials: 'include' });
const options = await optsRes.json();
const attResp = await startRegistration({ optionsJSON: options });
const verRes = await fetch('/auth/passkey/register/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
});
return verRes.json();
}
document.getElementById('enroll').addEventListener('click', enrollPasskey);
</script>
身份验证流程(使用条件式 UI)
条件式 UI 可让浏览器直接在用户名自动填充下拉列表中显示 Passkeys,用户可以在他们用于自动填充电子邮件的同一界面中选择 Passkey。
// Node, server side
const {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
app.post('/auth/passkey/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
// allowCredentials omitted → discoverable credentials → conditional UI works
});
// Tie the challenge to a temporary session marker
const challengeId = crypto.randomBytes(16).toString('hex');
await redisClient.setEx(`webauthn:auth:${challengeId}`, 300, options.challenge);
res.json({ options, challengeId });
});
app.post('/auth/passkey/login/verify', async (req, res) => {
const { challengeId, response } = req.body;
const expectedChallenge = await redisClient.get(`webauthn:auth:${challengeId}`);
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge expired' });
}
const credentialId = response.id;
const stored = await db.query(
`SELECT user_id, public_key, counter, transports FROM passkeys WHERE credential_id = $1`,
[credentialId]
);
if (stored.rows.length === 0) {
return res.status(401).json({ error: 'Unknown credential' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: credentialId,
publicKey: stored.rows[0].public_key,
counter: stored.rows[0].counter,
transports: stored.rows[0].transports || undefined,
},
requireUserVerification: false,
});
} catch (err) {
return res.status(401).json({ error: err.message });
}
if (!verification.verified) {
return res.status(401).json({ error: 'Authentication not verified' });
}
// Persist new counter
await db.query(
`UPDATE passkeys SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`,
[verification.authenticationInfo.newCounter, credentialId]
);
// Issue session/tokens as usual
req.session.regenerate(() => {
req.session.userId = stored.rows[0].user_id;
res.json({ verified: true });
});
});
<input type="email" name="email" autocomplete="username webauthn">
<script type="module" nonce="{{CSP_NONCE}}">
import { startAuthentication, browserSupportsWebAuthnAutofill }
from '/vendor/simplewebauthn-browser-13.3.0.mjs';
if (await browserSupportsWebAuthnAutofill()) {
const optsRes = await fetch('/auth/passkey/login/options', { method: 'POST' });
const { options, challengeId } = await optsRes.json();
const authResp = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true, // conditional UI
});
await fetch('/auth/passkey/login/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengeId, response: authResp }),
});
location.href = '/dashboard';
}
</script>
WebAuthn L3 信号方法
L3 为 PublicKeyCredential 添加了静态方法,让依赖方无需强制用户重新注册,即可使可同步的 Passkeys 与服务器状态保持一致:
signalUnknownCredential({ rpId, credentialId }),在尝试验证服务器已不再识别的凭据后调用。认证器会在用户的账户选择器 UI 中隐藏该凭据。signalAllAcceptedCredentials({ rpId, userId, allAcceptedCredentialIds }),在服务器的凭据列表变更后(撤销、注册)调用。认证器会清除不在列表中的凭据。signalCurrentUserDetails({ rpId, userId, name, displayName }),在个人资料变更后调用。认证器会更新选择器 UI 中的标签。
这些方法仅提供建议,且尽力而为;应将其视为维护手段,而非安全边界。
何时使用 webauthn(Python)
# pip install webauthn # PyPI package name is `webauthn`, NOT py_webauthn
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
)
RP_ID = "yourapp.com"
RP_NAME = "YourApp"
ORIGIN = "https://yourapp.com"
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=str(user_id).encode(),
user_name=user_email,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
)
密码重置流程
安全实现
const crypto = require('crypto');
// Request password reset
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// Always return success to prevent email enumeration
res.json({ message: 'If an account exists, a reset link has been sent.' });
// Find user (async, after response)
const result = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
return; // User doesn't exist, but don't reveal that
}
const user = result.rows[0];
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
// Store hashed token (not plain token)
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
// Send email with plain token
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
await sendEmail(email, 'Password Reset', `Reset your password: ${resetUrl}`);
});
// Reset password
app.post('/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ error: 'Token and new password required' });
}
// NIST 800-63B-4: 15-char minimum
if (newPassword.length < 15) {
return res.status(400).json({ error: 'Password must be at least 15 characters' });
}
if (await isPwned(newPassword)) {
return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
}
// Hash the provided token to compare with stored hash
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// Find valid reset token
const result = await db.query(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND expires_at > NOW() AND used = false`,
[tokenHash]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
const userId = result.rows[0].user_id;
// Hash new password with argon2id
const hashedPassword = await argon2.hash(newPassword, ARGON2_OPTS);
// Update password and invalidate token
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hashedPassword, userId]);
await db.query('UPDATE password_resets SET used = true WHERE token_hash = $1', [tokenHash]);
// Invalidate all existing sessions and refresh-token families for this user
await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
// (also revoke all refresh-token families for the user in your token store)
res.json({ success: true });
});
OAuth 2.1(以 Google 为例)
OAuth 2.1 当前为 draft-ietf-oauth-v2-1-15,日期为 2026-03-02(https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15)。它尚未成为 RFC,但 Google、Microsoft、Okta、Auth0 等提供商如今已经实施了这些约束,因此设计时应以 2.1 基线为准。
2.1 的要求(及移除的内容)
- 所有客户端都必须使用 PKCE,包括机密客户端。 仅支持 S256,
plain已弃用。 - 重定向 URI 必须进行精确字符串匹配。 不允许使用前缀或通配符匹配。
- 不允许使用隐式授权(
response_type=token)。 应使用带 PKCE 的授权码。 - 不允许使用资源所有者密码凭据(ROPC)。 第一方登录流程必须通过授权端点进行。
- 绝不在 URL 查询字符串中传递 Bearer 令牌。 始终使用 Authorization 标头。
服务器端带 PKCE 的授权码流程(Google)
const { OAuth2Client } = require('google-auth-library');
const crypto = require('crypto');
const oauth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
const { verifier, challenge } = pkcePair();
req.session.oauthState = state;
req.session.oauthVerifier = verifier;
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['email', 'profile'],
state,
prompt: 'consent',
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRF
if (!state || state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
const verifier = req.session.oauthVerifier;
delete req.session.oauthState;
delete req.session.oauthVerifier;
if (!verifier) {
return res.status(400).send('Missing PKCE verifier');
}
try {
// Exchange code for tokens, include the PKCE verifier
const { tokens } = await oauth2Client.getToken({
code,
codeVerifier: verifier,
});
oauth2Client.setCredentials(tokens);
// Validate the id_token: Google verifies signature; we MUST check audience
const ticket = await oauth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (payload.iss !== 'https://accounts.google.com' && payload.iss !== 'accounts.google.com') {
return res.status(400).send('Unexpected issuer');
}
const { sub: googleId, email, name, picture } = payload;
// Find or create user
let user = await db.query('SELECT id FROM users WHERE google_id = $1', [googleId]);
if (user.rows.length === 0) {
user = await db.query(
`INSERT INTO users (google_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4) RETURNING id`,
[googleId, email, name, picture]
);
}
// Create session
req.session.regenerate((err) => {
if (err) {
return res.status(500).send('Session error');
}
req.session.userId = user.rows[0].id;
res.redirect('/dashboard');
});
} catch (error) {
console.error('OAuth error:', error);
res.status(400).send('Authentication failed');
}
});
同样的模式适用于任何 OAuth 2.1 / OIDC 提供商,只需替换发行方、客户端、作用域和 userinfo 查询。请确认提供商控制台中的精确字符串重定向 URI 与服务器发送的内容一致。
MFA(优先使用通行密钥)
CISA《实施抗网络钓鱼 MFA》(2022-10-31,截至 2026-05 仍然有效;https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf)对验证因素的强度进行了排序:
- 抗网络钓鱼级别: FIDO2 / WebAuthn(通行密钥)、PKI 智能卡(PIV/CAC)。NIST AAL3 要求使用这类因素。
- 符合 AAL2 要求,但不抗网络钓鱼: 使用号码匹配的应用推送验证、通过身份验证器应用生成的 TOTP。
- 受限,仅作为最后手段: SMS / 语音 OTP。NIST 800-63B-4 §5 明确限制使用这些方式。
在 2026 年:不要将 SMS 添加为新的验证因素。 尽可能让用户停用 SMS。
将通行密钥作为第二验证因素
如果密码仍是第一个验证因素,已注册的通行密钥就是最强的第二验证因素。上文“通行密钥 / WebAuthn”部分中的注册和身份验证流程无需改动;在密码验证通过后,再要求用户通过通行密钥验证,才能访问 /auth/login。
TOTP(仍符合 AAL2 要求)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Enable MFA for user
app.post('/auth/mfa/enable', requireAuth(), async (req, res) => {
// Generate secret
const secret = speakeasy.generateSecret({
name: `YourApp:${req.user.email}`,
issuer: 'YourApp'
});
// Store secret (encrypted) temporarily until verified
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[encrypt(secret.base32), req.userId]
);
// Generate QR code
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // Show this as backup
qrCode: qrCode
});
});
// Verify and activate MFA
app.post('/auth/mfa/verify', requireAuth(), async (req, res) => {
const { code } = req.body;
const result = await db.query(
'SELECT mfa_secret_temp FROM users WHERE id = $1',
[req.userId]
);
const secret = decrypt(result.rows[0].mfa_secret_temp);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // Allow 1 step tolerance
});
if (!verified) {
return res.status(400).json({ error: 'Invalid code' });
}
// Move secret from temp to permanent
await db.query(
'UPDATE users SET mfa_secret = mfa_secret_temp, mfa_secret_temp = NULL, mfa_enabled = true WHERE id = $1',
[req.userId]
);
res.json({ success: true });
});
// Login with MFA
app.post('/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
// ... verify email/password first ...
if (user.mfa_enabled) {
if (!mfaCode) {
return res.status(401).json({
error: 'MFA code required',
requiresMfa: true
});
}
const verified = speakeasy.totp.verify({
secret: decrypt(user.mfa_secret),
encoding: 'base32',
token: mfaCode,
window: 1
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
}
// ... create session/token ...
});
SMS:2026 年不要将其添加为新的验证因素
如果你已在生产环境中使用 SMS,请规划停用方案。Snowflake(参见“数据泄露事件教训”)以及更广泛的 SIM 卡交换威胁表明,SMS 是一种风险,而非保障措施。NIST 800-63B-4 §5 对其作出了限制;CISA 建议停止使用。
常见的数据泄露事件教训
以下案例将常见模式与实际事件联系起来。每个案例都包含一段 2 至 3 句的摘要,以及上述示例中体现的教训。
Change Healthcare 勒索软件事件(2024 年 2 月)
攻击者利用被盗凭证访问一个未启用 MFA 的 Citrix 远程访问门户。服务中断持续数周;UnitedHealth Group 披露支付了约 2,200 万美元赎金,并有约三分之一的美国患者记录遭到泄露。参见 UnitedHealth Group 的 RCA:https://www.unitedhealthgroup.com/newsroom/2024/2024-04-22-uhg-update-on-change-healthcare-cyberattack.html。
教训: 所有远程访问门户、VPN、Citrix、RDP 网关和跳板机都必须启用 MFA,而不只是面向用户的应用。攻击者专门瞄准“内部”门户,正是因为这类门户通常防护较弱。
Snowflake / UNC5537(2024 年 4 月至 6 月)
约 165 个客户租户遭到入侵,原因是 Snowflake 的 MFA 由各租户自行选择启用;攻击者使用信息窃取恶意软件获取的凭证,入侵了未启用第二验证因素的账户。Mandiant 的分析见:https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion。Snowflake 从 2024 年 10 月起默认启用 MFA,并在 2025 至 2026 年期间逐步强制拦截仅使用密码的登录;当前推行计划请参阅 Snowflake 的 MFA 强制实施文档(https://docs.snowflake.com/)。
教训: MFA 必须默认启用,而不是由用户自行选择启用。如果客户可以将其关闭,攻击者就会找到那些关闭 MFA 的账户。
Storm-0558(CSRB 审查,2024-04)
Microsoft 消费者签名密钥遭到泄露;攻击者伪造 Azure AD 令牌,访问了企业 OWA 邮箱。直接原因是 OWA 的令牌验证库接受了由消费者密钥签名、用于访问企业邮箱的令牌,且跳过了范围、颁发者和签名密钥租户校验。CSRB 报告:https://www.cisa.gov/resources-tools/resources/CSRB-Review-Summer-2023-MEO-Intrusion。(Microsoft 的 MSRC 事后分析文章 URL 已停用。)
教训: 每次都要验证每一项声明:颁发者、受众、范围、过期时间和固定算法。对于多颁发者 / OIDC 部署,还要验证签名密钥所属租户:固定每个受信任颁发者可使用的 JWKS;即使 kid 匹配,也绝不能从其他颁发者的密钥集中选择验证密钥。上文的 JWT 示例已包含 iss / aud / scope / 固定算法校验,涵盖了声明级别的部分,适用于单颁发者的私有令牌部署。签名密钥所属租户这一部分(JWKS 解析和颁发者与密钥集的绑定)不在示例中;如果要接受多个租户颁发的令牌,请参阅 requireAuth 函数上方的注释块,了解需要补充的内容。
Okta HAR 泄露事件(2023 年 10 月)
客户将 HAR(HTTP Archive)调试文件上传给 Okta 支持团队;这些文件包含有效的会话令牌。五个客户会话遭到劫持。详见 Okta 的说明:https://sec.okta.com/articles/harfiles/。
**教训:**在日志边界处清理会话令牌和其他持有者凭证。HAR 上传文件、错误报告和调试转储在离开用户的浏览器前,应移除 Authorization 标头、set-cookie 以及符合已知令牌格式的值。
23andMe 凭证填充攻击(2023 年 10 月,2024 年达成和解)
攻击者利用其他数据泄露事件中重复使用的密码,对约 14,000 个账户实施凭证填充攻击,随后沿 DNA Relatives 社交图谱遍历,暴露了约 690 万名用户的数据。
**教训:**对关系和图谱端点设置每账户读取配额,而不只是每 IP 速率限制。单个遭入侵的账户不应能够以超过普通用户合理操作速度的方式枚举社交图谱。在注册和重置密码时进行泄露密码筛查(HIBP),即可彻底阻断这一入侵途径。
安全注意事项检查清单
在相关处于括号中注明 NIST AAL 术语。
密码存储
- 默认使用 argon2id,并采用 OWASP 的 5 种等效配置之一(m=47104/t=1、m=19456/t=2、m=12288/t=3、m=9216/t=4、m=7168/t=5;均为 p=1)
- 如果使用 bcrypt,成本至少为 10;对超过 72 字节的输入先使用 SHA-256 进行预哈希(或拒绝)
- 仅在需要符合 FIPS-140 时使用 PBKDF2
- 绝不存储明文密码
- 绝不记录密码(包括在错误处理程序中)
密码策略(NIST SP 800-63B-4)
- 单因素认证至少 15 个字符;多因素认证至少 8 个字符
- 最大长度至少为 64 个字符(验证方至少接受 64 个字符)
- 不设组合规则(“不得强制要求”)
- 允许使用 Unicode 和粘贴
- 不定期轮换
- 验证方会使用泄露密码阻止列表进行检查(HIBP k-匿名性或等效方式)
会话管理
- 会话存储在服务器端(而不只是存储在 Cookie 中)
- 会话 ID 采用密码学安全随机生成
- 登录时以及权限变更时重新生成会话
- 注销时以及重置密码时使会话失效
- 会话设置最长有效期
- Cookie 设置
httpOnly;生产环境设置secure;设置sameSite=lax或更严格的值
JWT 安全性
- 访问令牌的有效期较短(不超过 15 分钟)
- 刷新令牌存储为 httpOnly Cookie
- 轮换刷新令牌并检测重用(轮换后的令牌被使用两次 → 撤销整个令牌族)
- 存在令牌撤销机制
- 密钥至少为 256 位
- 每个请求都进行完整验证:颁发者、受众、到期时间、范围、固定签名算法(不允许
alg=none)、签名密钥租户归属(Storm-0558) - 对高价值 API 使用绑定令牌(DPoP,依据 RFC 9449;或 mTLS,依据 RFC 8705)
通行密钥 / WebAuthn
- 注册流程验证质询、来源和 RP ID
- 认证流程验证质询、来源、RP ID 和签名计数器
- 启用可发现凭证(
residentKey: 'preferred'),以支持条件式 UI - 服务器凭证状态变更时调用 WebAuthn L3 信号方法
- 制定验证器策略时考虑 CTAP 2.3
MFA(CISA 等级排名)
- AAL3 场景采用抗钓鱼等级的方案(FIDO2/WebAuthn、PIV/CAC)
- AAL2 采用基于应用的推送(带数字匹配)或 TOTP
- 2026 年不再新增 SMS 验证因素;将现有用户迁离 SMS
- MFA 默认开启,而非选择加入(Snowflake 的教训)
- 对每个远程访问门户强制执行 MFA(Change Healthcare 的教训)
OAuth 2.1
- 所有客户端(包括机密客户端)都使用 PKCE 和 S256
- 重定向 URI 采用精确字符串匹配
- 不使用隐式授权或 ROPC 授权
- 回调时验证 state 参数
- 验证
id_token的受众和颁发者
速率限制
- 按 IP 和账号限制登录尝试次数
- 失败 N 次后锁定账号
- 限制密码重置请求次数
- 限制 MFA 验证尝试次数
- 对账号的关系图 / 图谱端点设置配额(23andMe 的经验教训)
CSRF 防护
- 设置 SameSite Cookie 属性
- 对会更改状态的操作使用 CSRF 令牌
- 验证 OAuth state 参数
信息泄露
- 对有效用户和无效用户返回相同的错误消息
- 缓解时序攻击(用户不存在时进行虚拟哈希比较)
- 防止通过注册 / 重置流程枚举用户
- 在日志边界处剥离 Bearer 令牌(Okta HAR 的经验教训)