Install any skill in seconds. Free to start, no credit card required.
Get Started Free →OAuth 2.1 + JWT authentication security best practices. Use when implementing auth, API authorization, token management. Follows RFC 9700 (2025).
.claude/skills/majiayu000-auth-security/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 51% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 106% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 91% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 119% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 102% | 0% |
| Flow | Status | Replacement | |------|--------|-------------| | Implicit Grant | Removed | Authorization Code + PKCE | | Password Grant | Removed | Authorization Code + PKCE | | Auth Code without PKCE | Removed | Must use PKCE |
typescriptimport 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 }), });
| 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 |
typescript// 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);
typescriptinterface 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 }
typescriptimport { 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'); } }
typescript// 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 }); }
typescript// 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 memory
| 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 |
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 tokenstypescriptasync 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, }; }
typescript// WRONG: Trusts header algorithm jwt.verify(token, key); // Uses alg from header // CORRECT: Explicit algorithm jwt.verify(token, key, { algorithms: ['ES256'] });
typescript// 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 header
typescript// 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 localStorage
typescript// 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, }, });
typescript// 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; }
markdown## 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
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 22,797 | 14,944 | -34% | 1 | 1 | 0% | 4,671 | 6,025 | +29% | 0 | 0 | — |
case-02 | fail→pass | 23,505 | 18,803 | -20% | 1 | 1 | 0% | 4,756 | 7,198 | +51% | 0 | 0 | — |
case-03 | pass→pass | 13,493 | 11,180 | -17% | 1 | 1 | 0% | 2,697 | 5,360 | +99% | 0 | 0 | — |
case-04 | pass→pass | 15,211 | 15,392 | +1% | 1 | 1 | 0% | 2,935 | 6,307 | +115% | 0 | 0 | — |
case-05 | pass→pass | 10,891 | 6,706 | -38% | 1 | 1 | 0% | 1,978 | 4,333 | +119% | 0 | 0 | — |
case-06 | fail→pass | 12,188 | 8,369 | -31% | 1 | 1 | 0% | 2,287 | 4,722 | +106% | 0 | 0 | — |
case-07 | pass→pass | 12,064 | 9,349 | -23% | 1 | 1 | 0% | 2,322 | 5,050 | +117% | 0 | 0 | — |
case-16 | pass→pass | 18,837 | 15,092 | -20% | 1 | 1 | 0% | 3,675 | 6,176 | +68% | 0 | 0 | — |
case-08 | pass→pass | 16,658 | 17,030 | +2% | 1 | 1 | 0% | 3,042 | 6,516 | +114% | 0 | 0 | — |
case-09 | pass→pass | 15,346 | 8,578 | -44% | 1 | 1 | 0% | 2,806 | 4,861 | +73% | 0 | 0 | — |
case-10 | pass→pass | 12,402 | 11,362 | -8% | 1 | 1 | 0% | 2,258 | 5,459 | +142% | 0 | 0 | — |
case-11 | fail→pass | 16,055 | 13,643 | -15% | 1 | 1 | 0% | 2,988 | 5,696 | +91% | 0 | 0 | — |
case-12 | pass→pass | 13,402 | 7,491 | -44% | 1 | 1 | 0% | 2,696 | 4,535 | +68% | 0 | 0 | — |
case-13 | pass→pass | 15,218 | 16,348 | +7% | 1 | 1 | 0% | 2,915 | 6,385 | +119% | 0 | 0 | — |
case-14 | fail→pass | 14,884 | 15,222 | +2% | 1 | 1 | 0% | 2,675 | 5,851 | +119% | 0 | 0 | — |
case-15 | fail→pass | 14,486 | 11,239 | -22% | 1 | 1 | 0% | 2,549 | 5,141 | +102% | 0 | 0 | — |
case-17 | fail→fail | 22,373 | 18,127 | -19% | 1 | 1 | 0% | 4,397 | 6,877 | +56% | 0 | 0 | — |
case-18 | pass→pass | 16,945 | 9,172 | -46% | 1 | 1 | 0% | 3,384 | 4,911 | +45% | 0 | 0 | — |
case-19 | pass→pass | 11,059 | 9,014 | -18% | 1 | 1 | 0% | 1,927 | 4,709 | +144% | 0 | 0 | — |
case-20 | pass→pass | 14,239 | 12,834 | -10% | 1 | 1 | 0% | 2,649 | 5,585 | +111% | 0 | 0 | — |
case-21 | pass→pass | 16,219 | 10,963 | -32% | 1 | 1 | 0% | 2,386 | 5,272 | +121% | 0 | 0 | — |
case-22 | pass→pass | 11,145 | 10,981 | -1% | 1 | 1 | 0% | 2,123 | 5,293 | +149% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.