
Auth Security
- 97 installs
- 253 repo stars
- Updated August 4, 2026
- majiayu000/claude-arsenal
Helps with security tasks during AI-assisted development.
About
auth-security is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
- auth-security
- Security
- AI-coding skill
Auth Security by the numbers
- 97 all-time installs (skills.sh)
- Ranked #1,023 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/majiayu000/claude-arsenal --skill auth-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 97 |
|---|---|
| repo stars | ★ 253 |
| Last updated | August 4, 2026 |
| Repository | majiayu000/claude-arsenal ↗ |
What it does
Helps with security tasks during AI-assisted development.
Files
Auth Security
Core Principles
- OAuth 2.1 — Follow RFC 9700 (January 2025)
- PKCE Required — All clients must use PKCE
- Short-lived Tokens — Access tokens expire in 5-15 minutes
- Token Rotation — Refresh tokens are single-use
- HttpOnly Storage — Browser tokens in HttpOnly cookies
- Explicit Algorithm — Never trust JWT header algorithm
- No backwards compatibility — Delete deprecated auth flows
---
OAuth 2.1 Key Changes
Deprecated Flows (DO NOT USE)
| Flow | Status | Replacement |
|---|---|---|
| Implicit Grant | Removed | Authorization Code + PKCE |
| Password Grant | Removed | Authorization Code + PKCE |
| Auth Code without PKCE | Removed | Must use PKCE |
Required: Authorization Code + PKCE
import crypto from 'crypto';
// 1. Generate code verifier (43-128 chars)
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
// 2. Generate code challenge
function generateCodeChallenge(verifier: string): string {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// 3. Authorization request
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
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('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateState());
// 4. Token exchange (after redirect)
const tokenResponse = 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: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier, // Prove we initiated the request
}),
});---
JWT Best Practices
Algorithm Selection (2025)
| Priority | Algorithm | Notes |
|---|---|---|
| 1 | EdDSA (Ed25519) | Most secure, quantum-resistant properties |
| 2 | ES256 (ECDSA P-256) | Widely supported, compact signatures |
| 3 | PS256 (RSA-PSS) | More secure than RS256 |
| 4 | RS256 (RSA PKCS#1) | Best compatibility |
// Recommended: ES256
import { SignJWT, jwtVerify } from 'jose';
const privateKey = await importPKCS8(PRIVATE_KEY_PEM, 'ES256');
const publicKey = await importSPKI(PUBLIC_KEY_PEM, 'ES256');
// Sign
const token = await new SignJWT({ sub: userId, scope: 'read write' })
.setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid: keyId })
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setExpirationTime('15m')
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(privateKey);Token Structure
interface AccessTokenPayload {
// Standard claims
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string; // Audience
exp: number; // Expiration (Unix timestamp)
iat: number; // Issued at
jti: string; // JWT ID (unique identifier)
// Custom claims
scope: string; // Permissions
email?: string; // User email
roles?: string[]; // User roles
}Verification (Critical)
import { jwtVerify, errors } from 'jose';
async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
try {
const { payload } = await jwtVerify(token, publicKey, {
// CRITICAL: Explicitly specify allowed algorithms
algorithms: ['ES256'],
// Validate standard claims
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
// Clock tolerance for sync issues
clockTolerance: 30,
});
// Additional validation
if (!payload.scope?.includes('read')) {
throw new Error('Insufficient scope');
}
return payload as AccessTokenPayload;
} catch (err) {
if (err instanceof errors.JWTExpired) {
throw new AuthError('Token expired', 'TOKEN_EXPIRED');
}
if (err instanceof errors.JWTClaimValidationFailed) {
throw new AuthError('Invalid token claims', 'INVALID_CLAIMS');
}
throw new AuthError('Invalid token', 'INVALID_TOKEN');
}
}---
Token Storage
Web Applications
// Set token in HttpOnly cookie (server-side)
function setAuthCookie(res: Response, token: string) {
res.cookie('access_token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/api', // Only sent to API routes
});
}
// Refresh token (longer-lived)
function setRefreshCookie(res: Response, token: string) {
res.cookie('refresh_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only for refresh endpoint
});
}Single Page Applications (SPA)
// Store in memory (NOT localStorage/sessionStorage)
class TokenManager {
private accessToken: string | null = null;
setToken(token: string) {
this.accessToken = token;
}
getToken(): string | null {
return this.accessToken;
}
clearToken() {
this.accessToken = null;
}
}
// Use with Refresh Token Rotation
// Refresh token in HttpOnly cookie
// Access token in memoryStorage Comparison
| Storage | XSS Safe | CSRF Safe | Persistence |
|---|---|---|---|
| HttpOnly Cookie | Yes | Needs SameSite | Yes |
| Memory | Yes | Yes | No (lost on reload) |
| localStorage | No | Yes | Yes |
| sessionStorage | No | Yes | Tab only |
---
Refresh Token Rotation
Flow
1. Client sends refresh_token
2. Server validates refresh_token
3. Server generates NEW access_token + NEW refresh_token
4. Server INVALIDATES old refresh_token
5. Server returns new tokens
6. Client stores new tokensImplementation
async function refreshTokens(refreshToken: string) {
// Find token in database
const stored = await db.refreshToken.findUnique({
where: { token: hashToken(refreshToken) },
include: { user: true },
});
if (!stored) {
throw new AuthError('Invalid refresh token', 'INVALID_TOKEN');
}
// Check if already used (reuse detection)
if (stored.usedAt) {
// Potential token theft - revoke ALL user tokens
await db.refreshToken.deleteMany({
where: { userId: stored.userId },
});
// Alert security team
await alertSecurityTeam({
event: 'REFRESH_TOKEN_REUSE',
userId: stored.userId,
tokenId: stored.id,
});
throw new AuthError('Token reuse detected', 'TOKEN_REUSE');
}
// Check expiration
if (stored.expiresAt < new Date()) {
throw new AuthError('Refresh token expired', 'TOKEN_EXPIRED');
}
// Mark as used (but keep for reuse detection)
await db.refreshToken.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Generate new tokens
const newAccessToken = await generateAccessToken(stored.user);
const newRefreshToken = await generateRefreshToken(stored.user);
// Store new refresh token
await db.refreshToken.create({
data: {
token: hashToken(newRefreshToken),
userId: stored.userId,
expiresAt: addDays(new Date(), 7),
previousTokenId: stored.id, // Chain for audit
},
});
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}---
Attack Prevention
Algorithm Confusion
// WRONG: Trusts header algorithm
jwt.verify(token, key); // Uses alg from header
// CORRECT: Explicit algorithm
jwt.verify(token, key, { algorithms: ['ES256'] });CSRF Protection
// Use SameSite cookies
res.cookie('session', token, {
sameSite: 'strict', // or 'lax' for cross-site links
});
// Or double-submit cookie pattern
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf', csrfToken, { httpOnly: false });
// Client sends csrf token in headerXSS Protection
// Content Security Policy
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
].join('; '));
// Use HttpOnly cookies for tokens
// Never store tokens in localStorageToken Binding (DPoP)
// Demonstration of Proof of Possession
// Bind token to client's key pair
const dpopProof = await new SignJWT({
htm: 'POST',
htu: 'https://api.example.com/resource',
ath: await hashAccessToken(accessToken), // Access token hash
})
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicKey })
.setJti(crypto.randomUUID())
.setIssuedAt()
.sign(privateKey);
// Send with request
fetch('https://api.example.com/resource', {
headers: {
Authorization: `DPoP ${accessToken}`,
DPoP: dpopProof,
},
});---
Token Revocation
// Revoke all user tokens (e.g., password change, logout all)
async function revokeAllUserTokens(userId: string) {
await db.refreshToken.deleteMany({
where: { userId },
});
// If using token blacklist for access tokens
await redis.sadd(`revoked:${userId}`, Date.now());
await redis.expire(`revoked:${userId}`, 15 * 60); // 15 min (access token lifetime)
}
// Check blacklist during verification
async function isTokenRevoked(userId: string, iat: number): Promise<boolean> {
const revokedAt = await redis.get(`revoked:${userId}`);
return revokedAt && parseInt(revokedAt) > iat * 1000;
}---
Checklist
## OAuth 2.1
- [ ] Using Authorization Code flow
- [ ] PKCE enabled for all clients
- [ ] No implicit or password grants
- [ ] Redirect URI exact matching
## JWT
- [ ] Using ES256 or EdDSA algorithm
- [ ] Explicit algorithm verification
- [ ] Short expiration (≤15 min)
- [ ] Unique jti for each token
- [ ] Issuer and audience validation
## Tokens
- [ ] HttpOnly cookies for web apps
- [ ] Refresh token rotation enabled
- [ ] Reuse detection implemented
- [ ] Token revocation mechanism
## Security
- [ ] HTTPS everywhere
- [ ] SameSite cookies
- [ ] CSP headers configured
- [ ] Rate limiting on auth endpoints
- [ ] Brute force protection---
See Also
- reference/oauth2.1.md — OAuth 2.1 deep dive
- reference/jwt.md — JWT patterns
- reference/attacks.md — Attack prevention
- templates/typescript/auth.service.ts — TypeScript auth service starter
Auth Attack Prevention
OAuth/OIDC Attacks
Authorization Code Injection
Attack: Attacker intercepts authorization code and uses it.
Prevention:
// PKCE prevents code injection
// Code is useless without code_verifier
// Also: Bind code to client
const code = {
value: crypto.randomBytes(32).toString('base64url'),
clientId: request.clientId,
redirectUri: request.redirectUri,
codeChallenge: request.codeChallenge,
userId: authenticatedUser.id,
expiresAt: Date.now() + 60000, // 1 minute
};
// Validate all parameters match during exchange
function validateCodeExchange(code, request) {
if (code.clientId !== request.clientId) throw new Error();
if (code.redirectUri !== request.redirectUri) throw new Error();
// PKCE verification
const challenge = sha256(request.codeVerifier);
if (code.codeChallenge !== challenge) throw new Error();
}Redirect URI Manipulation
Attack: Attacker modifies redirect_uri to steal tokens.
Prevention:
// Exact match only
function validateRedirectUri(requested: string, client: Client): boolean {
return client.registeredRedirectUris.includes(requested);
}
// NO pattern matching
// NO subdomain wildcards
// NO path prefixesCSRF on Authorization Endpoint
Attack: Attacker tricks user into authorizing attacker's account.
Prevention:
// Use state parameter
const state = crypto.randomBytes(32).toString('base64url');
sessionStorage.setItem('oauth_state', state);
// Validate on callback
if (callbackState !== sessionStorage.getItem('oauth_state')) {
throw new Error('CSRF detected');
}Token Leakage via Referrer
Attack: Token in URL fragment leaked via Referer header.
Prevention:
// Use Authorization Code flow (not Implicit)
// Tokens never in URL
// If tokens must be in URL (legacy):
res.setHeader('Referrer-Policy', 'no-referrer');---
JWT Attacks
Algorithm Confusion
Attack: Change algorithm from RS256 to HS256, sign with public key.
Prevention:
// ALWAYS specify allowed algorithms
const payload = jwt.verify(token, key, {
algorithms: ['ES256'], // Explicit whitelist
});
// NEVER trust header.alg blindlyNone Algorithm
Attack: Set algorithm to "none", remove signature.
Prevention:
// Reject 'none' algorithm
const allowedAlgorithms = ['ES256', 'RS256'];
if (!allowedAlgorithms.includes(header.alg)) {
throw new Error('Invalid algorithm');
}Key Injection (JKU/X5U)
Attack: Inject malicious key URL in header.
Prevention:
// Whitelist key sources
const trustedJwksSources = [
'https://auth.example.com/.well-known/jwks.json',
];
// Or ignore jku/x5u entirely, use configured keys only
if (header.jku && !trustedJwksSources.includes(header.jku)) {
throw new Error('Untrusted key source');
}Signature Stripping
Attack: Remove signature, modify payload.
Prevention:
// Verify signature BEFORE parsing claims
// All JWT libraries should do this by default
const { payload } = await jwtVerify(token, key);
// Check token has 3 parts
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT structure');
}---
Session Attacks
Session Fixation
Attack: Attacker sets victim's session ID before authentication.
Prevention:
// Regenerate session ID after login
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
// Destroy old session
req.session.destroy();
// Create new session with new ID
req.session.regenerate((err) => {
req.session.userId = user.id;
res.redirect('/dashboard');
});
});Session Hijacking
Attack: Steal session cookie via XSS or network sniffing.
Prevention:
// Secure cookie settings
app.use(session({
cookie: {
httpOnly: true, // No JavaScript access
secure: true, // HTTPS only
sameSite: 'strict', // No cross-site sending
maxAge: 3600000, // 1 hour
},
name: '__Host-session', // Cookie prefix for extra security
}));
// Bind session to client
req.session.userAgent = req.headers['user-agent'];
req.session.ip = req.ip;
// Validate on each request
if (req.session.userAgent !== req.headers['user-agent']) {
req.session.destroy();
throw new Error('Session binding mismatch');
}---
Token Attacks
Refresh Token Theft
Attack: Steal refresh token, get unlimited access tokens.
Prevention:
// Refresh token rotation
async function refresh(token: string) {
const stored = await db.refreshToken.findUnique({ where: { token } });
// Detect reuse (token already used)
if (stored.usedAt) {
// Revoke ALL user tokens
await db.refreshToken.deleteMany({ where: { userId: stored.userId } });
throw new Error('Token reuse detected - possible theft');
}
// Mark as used
await db.refreshToken.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Issue new tokens
return generateTokens(stored.userId);
}Token Replay
Attack: Reuse valid token for unauthorized requests.
Prevention:
// Short-lived access tokens
const accessToken = jwt.sign(payload, key, { expiresIn: '15m' });
// Unique token ID
payload.jti = crypto.randomUUID();
// For high-security: One-time tokens
const usedTokens = new Set();
function validateOneTimeToken(token) {
if (usedTokens.has(token.jti)) {
throw new Error('Token already used');
}
usedTokens.add(token.jti);
}Token Sidejacking
Attack: Steal token in transit.
Prevention:
// HTTPS everywhere
// HSTS header
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Token binding (DPoP)
const dpopProof = createDPoPProof(privateKey, method, url, accessToken);---
Brute Force Attacks
Password Guessing
Prevention:
// Rate limiting
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
keyGenerator: (req) => req.body.email,
});
// Account lockout
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes
async function login(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new Error('Account locked');
}
if (!await verifyPassword(password, user.passwordHash)) {
await db.user.update({
where: { id: user.id },
data: {
failedAttempts: { increment: 1 },
lockedUntil: user.failedAttempts >= MAX_FAILED_ATTEMPTS - 1
? new Date(Date.now() + LOCKOUT_DURATION)
: null,
},
});
throw new Error('Invalid credentials');
}
// Reset on success
await db.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockedUntil: null },
});
}Token Guessing
Prevention:
// Sufficient entropy
const token = crypto.randomBytes(32).toString('base64url');
// 256 bits of entropy - infeasible to guess
// Constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(providedToken),
Buffer.from(storedToken)
);---
XSS and CSRF
XSS Token Theft
Prevention:
// HttpOnly cookies (tokens not accessible to JS)
res.cookie('token', token, { httpOnly: true });
// Content Security Policy
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
].join('; '));
// Never store tokens in localStorage
// ❌ localStorage.setItem('token', token);CSRF Token Theft
Prevention:
// SameSite cookies
res.cookie('session', token, {
sameSite: 'strict', // or 'lax'
});
// CSRF token for non-GET requests
app.use(csrf());
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken();
next();
});JWT Deep Dive
Token Types
Access Token
Short-lived, used for API authorization.
interface AccessToken {
// Header
alg: 'ES256';
typ: 'at+jwt'; // RFC 9068
kid: string;
// Payload
iss: string; // https://auth.example.com
sub: string; // User ID
aud: string; // https://api.example.com
exp: number; // 15 minutes from now
iat: number;
jti: string; // Unique ID
scope: string; // 'read write'
client_id: string;
}Refresh Token
Longer-lived, used to obtain new access tokens.
// Can be JWT or opaque string
// Opaque recommended (stored server-side)
const refreshToken = crypto.randomBytes(32).toString('base64url');
// If JWT, use longer expiration
interface RefreshToken {
alg: 'ES256';
typ: 'rt+jwt';
iss: string;
sub: string;
aud: string;
exp: number; // 7-30 days
iat: number;
jti: string;
}ID Token (OpenID Connect)
Contains user identity information.
interface IdToken {
// Header
alg: 'ES256';
typ: 'JWT';
kid: string;
// Payload
iss: string;
sub: string; // Unique user identifier
aud: string; // Client ID
exp: number;
iat: number;
auth_time: number; // When user authenticated
nonce: string; // Replay protection
// Optional claims
name?: string;
email?: string;
email_verified?: boolean;
picture?: string;
}---
Signing & Verification
Key Management
import { generateKeyPair, exportJWK, importJWK } from 'jose';
// Generate key pair
async function generateSigningKey() {
const { publicKey, privateKey } = await generateKeyPair('ES256', {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const privateJwk = await exportJWK(privateKey);
// Add key ID
const kid = crypto.randomUUID();
publicJwk.kid = kid;
privateJwk.kid = kid;
publicJwk.use = 'sig';
privateJwk.use = 'sig';
publicJwk.alg = 'ES256';
privateJwk.alg = 'ES256';
return { publicJwk, privateJwk, kid };
}Key Rotation
class KeyManager {
private keys: Map<string, CryptoKeyPair> = new Map();
private currentKeyId: string;
async rotate() {
const { publicJwk, privateJwk, kid } = await generateSigningKey();
const publicKey = await importJWK(publicJwk, 'ES256');
const privateKey = await importJWK(privateJwk, 'ES256');
this.keys.set(kid, { publicKey, privateKey });
this.currentKeyId = kid;
// Keep old keys for verification (token lifetime)
// Remove after 2x max token lifetime
}
getSigningKey(): { key: CryptoKey; kid: string } {
return {
key: this.keys.get(this.currentKeyId)!.privateKey,
kid: this.currentKeyId,
};
}
getVerificationKey(kid: string): CryptoKey | undefined {
return this.keys.get(kid)?.publicKey;
}
getJWKS(): { keys: JsonWebKey[] } {
return {
keys: Array.from(this.keys.entries()).map(([kid, pair]) => ({
...pair.publicKey,
kid,
})),
};
}
}JWKS Endpoint
// GET /.well-known/jwks.json
app.get('/.well-known/jwks.json', (req, res) => {
res.json(keyManager.getJWKS());
});
// Client fetches and caches JWKS
import { createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['ES256'],
issuer: 'https://auth.example.com',
});---
Claim Validation
Required Validations
async function validateToken(token: string): Promise<TokenPayload> {
// 1. Decode and verify signature
const { payload, protectedHeader } = await jwtVerify(token, getKey, {
algorithms: ['ES256'],
});
// 2. Validate issuer
if (payload.iss !== 'https://auth.example.com') {
throw new Error('Invalid issuer');
}
// 3. Validate audience
const validAudiences = ['https://api.example.com'];
if (!validAudiences.includes(payload.aud as string)) {
throw new Error('Invalid audience');
}
// 4. Validate expiration (handled by jwtVerify)
// 5. Validate not before (handled by jwtVerify)
// 6. Validate token type
if (protectedHeader.typ !== 'at+jwt') {
throw new Error('Invalid token type');
}
// 7. Validate required claims exist
if (!payload.sub || !payload.scope) {
throw new Error('Missing required claims');
}
return payload as TokenPayload;
}Custom Claim Validation
function validateScope(payload: TokenPayload, required: string[]): void {
const tokenScopes = (payload.scope as string).split(' ');
const missing = required.filter(s => !tokenScopes.includes(s));
if (missing.length > 0) {
throw new InsufficientScopeError(missing);
}
}
function validateRoles(payload: TokenPayload, required: string[]): void {
const userRoles = payload.roles || [];
const missing = required.filter(r => !userRoles.includes(r));
if (missing.length > 0) {
throw new InsufficientRoleError(missing);
}
}---
Security Patterns
Audience Restriction
// Different tokens for different APIs
const apiAudiences = {
'https://api.example.com': ['read', 'write'],
'https://admin.example.com': ['admin'],
'https://internal.example.com': ['internal'],
};
// Validate audience matches the API being accessed
function validateAudienceForApi(token: TokenPayload, apiUrl: string): void {
if (token.aud !== apiUrl) {
throw new Error(`Token not valid for ${apiUrl}`);
}
}Token Binding
// Bind token to client fingerprint
interface BoundToken extends TokenPayload {
cnf: {
'x5t#S256': string; // Certificate thumbprint
jkt: string; // JWK thumbprint (for DPoP)
};
}
// Validate binding
function validateTokenBinding(token: BoundToken, clientJwk: JsonWebKey): void {
const thumbprint = await calculateJwkThumbprint(clientJwk, 'sha256');
if (token.cnf.jkt !== thumbprint) {
throw new Error('Token not bound to this client');
}
}Phantom Token Pattern
// Public: Opaque reference token
// Internal: JWT with full claims
// Token endpoint returns opaque token
app.post('/token', (req, res) => {
const jwt = generateFullJwt(user);
const reference = crypto.randomBytes(32).toString('base64url');
// Store mapping
await redis.set(`token:${reference}`, jwt, 'EX', 900);
res.json({ access_token: reference, token_type: 'Bearer' });
});
// API Gateway exchanges for JWT
async function exchangeToken(reference: string): Promise<string> {
const jwt = await redis.get(`token:${reference}`);
if (!jwt) throw new Error('Invalid token');
return jwt;
}---
Common Vulnerabilities
Algorithm Confusion
// VULNERABLE: Accepts algorithm from header
const decoded = jwt.verify(token, secret);
// SECURE: Explicit algorithm
const decoded = jwt.verify(token, key, { algorithms: ['ES256'] });
// VULNERABLE: Accepts 'none' algorithm
// SECURE: Never allow 'none'Key Confusion
// VULNERABLE: Symmetric key treated as public key
// Attacker signs with public key, server verifies with same key
// SECURE: Different key types for different algorithms
function getVerificationKey(header: JWTHeader) {
if (header.alg.startsWith('RS') || header.alg.startsWith('ES')) {
return asymmetricPublicKey;
} else if (header.alg.startsWith('HS')) {
return symmetricSecret;
}
throw new Error('Unsupported algorithm');
}JKU/X5U Injection
// VULNERABLE: Fetches key from untrusted URL in header
const { jku } = header;
const keys = await fetch(jku).then(r => r.json());
// SECURE: Whitelist allowed key URLs
const allowedJku = ['https://auth.example.com/.well-known/jwks.json'];
if (!allowedJku.includes(header.jku)) {
throw new Error('Untrusted key source');
}Weak Secrets
// VULNERABLE: Short, predictable secret
const secret = 'password123';
// SECURE: Cryptographically random, sufficient length
const secret = crypto.randomBytes(32).toString('base64');
// Or use asymmetric keys (preferred)OAuth 2.1 Deep Dive
RFC 9700 Summary
Published January 2025, consolidates security best practices from:
- RFC 6749 (OAuth 2.0)
- RFC 7636 (PKCE)
- RFC 8252 (Native Apps)
- RFC 6819 (Threat Model)
- Various security BCPs
---
Grant Types
Authorization Code (REQUIRED)
The only grant type for user authentication.
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Client │────▶│ Auth │────▶│ Resource │
│ App │◀────│ Server │◀────│ Server │
└──────────┘ └───────────┘ └──────────────┘
1. Client redirects to Auth Server with PKCE challenge
2. User authenticates
3. Auth Server redirects back with code
4. Client exchanges code + verifier for tokens
5. Client accesses Resource Server with access tokenClient Credentials
For server-to-server communication (no user context).
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api:read api:write',
}),
});Refresh Token
For obtaining new access tokens without re-authentication.
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: currentRefreshToken,
client_id: CLIENT_ID,
}),
});
const { access_token, refresh_token } = await response.json();
// IMPORTANT: Store new refresh_token, old one is invalidated---
PKCE Implementation
Code Verifier Requirements
- Length: 43-128 characters
- Characters:
[A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" - Must be cryptographically random
function generateCodeVerifier(): string {
// 32 bytes = 43 base64url characters
const buffer = crypto.randomBytes(32);
return buffer.toString('base64url');
}Code Challenge
// S256 method (REQUIRED if supported)
function generateCodeChallenge(verifier: string): string {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// Plain method (only if S256 not supported)
// NOT RECOMMENDED - only for legacy compatibility
function generatePlainChallenge(verifier: string): string {
return verifier;
}Full PKCE Flow
class PKCEFlow {
private verifier: string;
private state: string;
constructor() {
this.verifier = generateCodeVerifier();
this.state = crypto.randomBytes(16).toString('hex');
}
getAuthorizationUrl(config: AuthConfig): string {
const url = new URL(config.authorizationEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', config.clientId);
url.searchParams.set('redirect_uri', config.redirectUri);
url.searchParams.set('scope', config.scope);
url.searchParams.set('state', this.state);
url.searchParams.set('code_challenge', generateCodeChallenge(this.verifier));
url.searchParams.set('code_challenge_method', 'S256');
// OpenID Connect
if (config.scope.includes('openid')) {
url.searchParams.set('nonce', crypto.randomBytes(16).toString('hex'));
}
return url.toString();
}
async exchangeCode(code: string, receivedState: string, config: AuthConfig): Promise<TokenResponse> {
// Validate state
if (receivedState !== this.state) {
throw new Error('State mismatch - possible CSRF attack');
}
const response = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: this.verifier,
}),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error_description || error.error);
}
return response.json();
}
}---
Client Authentication
Confidential Clients
// Method 1: client_secret_basic (Header)
const credentials = btoa(`${clientId}:${clientSecret}`);
headers['Authorization'] = `Basic ${credentials}`;
// Method 2: client_secret_post (Body)
body.append('client_id', clientId);
body.append('client_secret', clientSecret);
// Method 3: private_key_jwt (Recommended for high security)
const assertion = await new SignJWT({
iss: clientId,
sub: clientId,
aud: tokenEndpoint,
})
.setProtectedHeader({ alg: 'ES256' })
.setExpirationTime('5m')
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(privateKey);
body.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
body.append('client_assertion', assertion);Public Clients
- Mobile apps, SPAs, desktop apps
- Cannot store secrets securely
- MUST use PKCE
- client_id only (no secret)
---
Redirect URI Validation
Server-Side
function validateRedirectUri(requested: string, registered: string[]): boolean {
// MUST be exact match (no pattern matching)
return registered.includes(requested);
}
// Avoid these patterns (security risks):
// ❌ Wildcard subdomains: https://*.example.com/callback
// ❌ Path prefix: https://example.com/callback/*
// ❌ Query parameter variationsRegistered URIs
const registeredRedirectUris = [
'https://app.example.com/callback',
'https://staging.example.com/callback',
// Mobile: Custom scheme
'com.example.app://callback',
// Desktop: Loopback
'http://127.0.0.1/callback',
'http://[::1]/callback',
];---
Token Response
Standard Fields
interface TokenResponse {
access_token: string;
token_type: 'Bearer' | 'DPoP';
expires_in: number; // Seconds until expiration
refresh_token?: string; // Optional
scope?: string; // May differ from requested
id_token?: string; // OpenID Connect
}Error Response
interface TokenErrorResponse {
error: 'invalid_request' | 'invalid_client' | 'invalid_grant' |
'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope';
error_description?: string;
error_uri?: string;
}---
Scopes
Design Guidelines
// Resource-based scopes
const scopes = [
'users:read',
'users:write',
'orders:read',
'orders:write',
'admin:full',
];
// Action-based scopes
const scopes = [
'read',
'write',
'delete',
'admin',
];
// OpenID Connect scopes
const oidcScopes = [
'openid', // Required for OIDC
'profile', // name, family_name, etc.
'email', // email, email_verified
'address', // address claim
'phone', // phone_number, phone_number_verified
];Scope Validation
function validateScopes(requested: string[], allowed: string[]): string[] {
// Return intersection of requested and allowed
return requested.filter(scope => allowed.includes(scope));
}
function hasScope(tokenScopes: string, required: string): boolean {
const scopes = tokenScopes.split(' ');
return scopes.includes(required);
}---
Security Considerations
State Parameter
// Generate cryptographically random state
const state = crypto.randomBytes(32).toString('base64url');
// Store in session before redirect
session.oauthState = state;
// Validate on callback
if (callbackState !== session.oauthState) {
throw new Error('Invalid state - CSRF detected');
}Nonce (OpenID Connect)
// Include in authorization request
url.searchParams.set('nonce', crypto.randomBytes(16).toString('hex'));
// Validate in ID token
if (idToken.nonce !== session.nonce) {
throw new Error('Invalid nonce - replay attack detected');
}Token Binding
// DPoP (Demonstration of Proof of Possession)
// Binds tokens to client's key pair
// 1. Client generates key pair
const { publicKey, privateKey } = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
);
// 2. Create DPoP proof for token request
const dpopProof = await createDPoPProof(privateKey, {
htm: 'POST',
htu: tokenEndpoint,
});
// 3. Include in token request
headers['DPoP'] = dpopProof;
// 4. Server binds token to public key
// 5. All API requests require fresh DPoP proof/**
* Authentication Service
* OAuth 2.1 + JWT implementation following RFC 9700
*/
import { SignJWT, jwtVerify, createRemoteJWKSet, errors } from 'jose';
import crypto from 'crypto';
// ============================================
// Types
// ============================================
interface TokenPayload {
iss: string;
sub: string;
aud: string;
exp: number;
iat: number;
jti: string;
scope: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface PKCEChallenge {
verifier: string;
challenge: string;
}
// ============================================
// Configuration
// ============================================
const config = {
issuer: process.env.AUTH_ISSUER || 'https://auth.example.com',
audience: process.env.AUTH_AUDIENCE || 'https://api.example.com',
accessTokenTTL: 15 * 60, // 15 minutes
refreshTokenTTL: 7 * 24 * 60 * 60, // 7 days
algorithm: 'ES256' as const,
};
// ============================================
// PKCE
// ============================================
export function generatePKCE(): PKCEChallenge {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
export function verifyPKCE(verifier: string, challenge: string): boolean {
const computed = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return crypto.timingSafeEqual(
Buffer.from(computed),
Buffer.from(challenge)
);
}
// ============================================
// Token Generation
// ============================================
export async function generateAccessToken(
privateKey: CryptoKey,
keyId: string,
userId: string,
scope: string
): Promise<string> {
return new SignJWT({
scope,
})
.setProtectedHeader({
alg: config.algorithm,
typ: 'at+jwt',
kid: keyId,
})
.setIssuer(config.issuer)
.setSubject(userId)
.setAudience(config.audience)
.setExpirationTime(`${config.accessTokenTTL}s`)
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(privateKey);
}
export function generateRefreshToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
export async function generateTokenPair(
privateKey: CryptoKey,
keyId: string,
userId: string,
scope: string
): Promise<TokenPair> {
const [accessToken, refreshToken] = await Promise.all([
generateAccessToken(privateKey, keyId, userId, scope),
Promise.resolve(generateRefreshToken()),
]);
return {
accessToken,
refreshToken,
expiresIn: config.accessTokenTTL,
};
}
// ============================================
// Token Verification
// ============================================
const JWKS = createRemoteJWKSet(
new URL(`${config.issuer}/.well-known/jwks.json`)
);
export async function verifyAccessToken(token: string): Promise<TokenPayload> {
try {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
// CRITICAL: Explicit algorithm whitelist
algorithms: [config.algorithm],
issuer: config.issuer,
audience: config.audience,
clockTolerance: 30,
});
// Validate token type
if (protectedHeader.typ !== 'at+jwt') {
throw new AuthError('Invalid token type', 'INVALID_TOKEN_TYPE');
}
// Validate required claims
if (!payload.sub || !payload.scope) {
throw new AuthError('Missing required claims', 'MISSING_CLAIMS');
}
return payload as TokenPayload;
} catch (err) {
if (err instanceof errors.JWTExpired) {
throw new AuthError('Token expired', 'TOKEN_EXPIRED');
}
if (err instanceof errors.JWTClaimValidationFailed) {
throw new AuthError('Invalid claims', 'INVALID_CLAIMS');
}
if (err instanceof AuthError) {
throw err;
}
throw new AuthError('Invalid token', 'INVALID_TOKEN');
}
}
// ============================================
// Scope Validation
// ============================================
export function validateScope(
tokenScope: string,
requiredScopes: string[]
): void {
const scopes = tokenScope.split(' ');
const missing = requiredScopes.filter(s => !scopes.includes(s));
if (missing.length > 0) {
throw new AuthError(
`Missing scopes: ${missing.join(', ')}`,
'INSUFFICIENT_SCOPE'
);
}
}
export function hasScope(tokenScope: string, scope: string): boolean {
return tokenScope.split(' ').includes(scope);
}
// ============================================
// Refresh Token Rotation
// ============================================
interface StoredRefreshToken {
id: string;
token: string;
userId: string;
usedAt: Date | null;
expiresAt: Date;
}
// In production, use database
const refreshTokenStore = new Map<string, StoredRefreshToken>();
export async function storeRefreshToken(
token: string,
userId: string
): Promise<void> {
const hashed = hashToken(token);
refreshTokenStore.set(hashed, {
id: crypto.randomUUID(),
token: hashed,
userId,
usedAt: null,
expiresAt: new Date(Date.now() + config.refreshTokenTTL * 1000),
});
}
export async function rotateRefreshToken(
oldToken: string,
privateKey: CryptoKey,
keyId: string
): Promise<TokenPair> {
const hashed = hashToken(oldToken);
const stored = refreshTokenStore.get(hashed);
if (!stored) {
throw new AuthError('Invalid refresh token', 'INVALID_TOKEN');
}
// Check if already used (reuse detection)
if (stored.usedAt) {
// Potential token theft - revoke all user tokens
revokeAllUserTokens(stored.userId);
throw new AuthError('Token reuse detected', 'TOKEN_REUSE');
}
// Check expiration
if (stored.expiresAt < new Date()) {
throw new AuthError('Refresh token expired', 'TOKEN_EXPIRED');
}
// Mark as used
stored.usedAt = new Date();
// Generate new tokens
const newTokens = await generateTokenPair(
privateKey,
keyId,
stored.userId,
'read write' // Get scope from stored token in production
);
// Store new refresh token
await storeRefreshToken(newTokens.refreshToken, stored.userId);
return newTokens;
}
// ============================================
// Token Revocation
// ============================================
export function revokeAllUserTokens(userId: string): void {
for (const [key, token] of refreshTokenStore.entries()) {
if (token.userId === userId) {
refreshTokenStore.delete(key);
}
}
}
export function revokeRefreshToken(token: string): void {
const hashed = hashToken(token);
refreshTokenStore.delete(hashed);
}
// ============================================
// Utilities
// ============================================
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
// ============================================
// Errors
// ============================================
export class AuthError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 401
) {
super(message);
this.name = 'AuthError';
}
}
// ============================================
// Middleware (Express example)
// ============================================
import type { Request, Response, NextFunction } from 'express';
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
export function authMiddleware(requiredScopes?: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new AuthError('Missing authorization header', 'MISSING_AUTH');
}
const token = authHeader.slice(7);
const payload = await verifyAccessToken(token);
if (requiredScopes) {
validateScope(payload.scope, requiredScopes);
}
req.user = payload;
next();
} catch (err) {
if (err instanceof AuthError) {
res.status(err.statusCode).json({
error: err.code,
message: err.message,
});
} else {
res.status(500).json({ error: 'INTERNAL_ERROR' });
}
}
};
}