Authentication is the area of application development with the highest cost of mistakes — a bug in your auth layer is a security vulnerability, not just a broken feature. Claude Code generates authentication code correctly because it knows the security requirements: timing-safe comparisons, secure token storage, proper session invalidation, and common attack vectors.
This guide covers implementing authentication with Claude Code: JWT tokens, OAuth 2.0/OIDC integration, session management, refresh token rotation, and role-based access control.
Setting Up Claude Code for Auth Development
Auth context prevents insecure defaults:
# Authentication Context
## Stack & Approach
- Node.js/Express API, stateless JWT auth
- Access tokens: 15-minute expiry, signed HS256
- Refresh tokens: 7-day expiry, stored in database (rotated on use)
- Passwords: bcrypt (work factor 12)
- Sessions: httpOnly cookie for web clients, Bearer token for mobile/API
## Security Requirements
- Timing-safe comparison for all secret comparison (crypto.timingSafeEqual)
- Never log tokens, passwords, or sensitive data
- Rate limiting on all auth endpoints (login, register, password reset)
- Account lockout after 5 failed login attempts
- HTTPS only — SameSite=Strict cookies
## Never
- Store plain passwords
- Store tokens in localStorage (XSS vulnerable) — use httpOnly cookies for web
- Return user existence in "user not found" vs "wrong password" (oracle)
- Skip token expiry validation
See the CLAUDE.md setup guide for full configuration.
JWT Authentication
Access and Refresh Token Pattern
Implement JWT authentication.
Access token: 15 min expiry.
Refresh token: 7 days, stored in DB, rotated on each use.
Login endpoint: return both tokens.
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
function generateTokens(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
const refreshToken = randomBytes(64).toString('hex'); // Not a JWT — opaque token
return { accessToken, refreshToken };
}
async function login(email: string, password: string): Promise<LoginResult> {
// Same error for wrong email vs wrong password — prevents user enumeration
const GENERIC_ERROR = { error: 'Invalid email or password' };
const user = await db.user.findUnique({ where: { email } });
// Always run bcrypt.compare even if user not found (prevent timing oracle)
const DUMMY_HASH = '$2b$12$invalidhashplaceholderusedforty'; // Fake bcrypt hash
const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
const passwordValid = await bcrypt.compare(password, hashToCompare);
if (!user || !passwordValid) {
return { success: false, ...GENERIC_ERROR };
}
// Check account lockout
if (user.lockedUntil && user.lockedUntil > new Date()) {
return { success: false, error: 'Account temporarily locked. Try again later.' };
}
// Reset failed attempts on success
await db.user.update({
where: { id: user.id },
data: { failedLoginAttempts: 0, lockedUntil: null },
});
const { accessToken, refreshToken } = generateTokens(user.id);
// Store hashed refresh token
await db.refreshToken.create({
data: {
userId: user.id,
tokenHash: await bcrypt.hash(refreshToken, 10),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { success: true, accessToken, refreshToken, user: { id: user.id, email: user.email } };
}
The dummy hash approach is critical: without it, the response time for “user not found” would be faster than “wrong password” (which runs bcrypt). Attackers can use this timing difference to enumerate valid emails.
Refresh Token Rotation
Implement the refresh token endpoint.
Exchange a valid refresh token for a new access + refresh token pair.
Revoke the old refresh token (rotation).
Detect refresh token reuse (indicates theft).
async function refreshTokens(providedToken: string): Promise<RefreshResult> {
// Find matching token (check all valid tokens for this — don't use hash lookup
// because we store hashed tokens)
const tokens = await db.refreshToken.findMany({
where: {
expiresAt: { gt: new Date() },
revokedAt: null,
},
include: { user: true },
});
// Find the matching token
let matchedToken = null;
for (const token of tokens) {
if (await bcrypt.compare(providedToken, token.tokenHash)) {
matchedToken = token;
break;
}
}
if (!matchedToken) {
// Token not found OR already used — potential theft detected
// If the token was already revoked, revoke the whole family
const revokedToken = await db.refreshToken.findFirst({
where: { revokedAt: { not: null } },
});
if (revokedToken) {
// Reuse detected — revoke all tokens for this user
await db.refreshToken.updateMany({
where: { userId: revokedToken.userId },
data: { revokedAt: new Date() },
});
}
return { success: false, error: 'Invalid or expired refresh token' };
}
// Revoke the used token
await db.refreshToken.update({
where: { id: matchedToken.id },
data: { revokedAt: new Date() },
});
// Issue new tokens
const { accessToken, refreshToken: newRefreshToken } = generateTokens(matchedToken.userId);
await db.refreshToken.create({
data: {
userId: matchedToken.userId,
tokenHash: await bcrypt.hash(newRefreshToken, 10),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { success: true, accessToken, refreshToken: newRefreshToken };
}
Refresh token rotation: each use invalidates the old token and issues a new one. If an attacker steals and uses a token before the legitimate user, the legitimate user will find their token invalid on next use — triggering the reuse detection that revokes all their tokens.
OAuth 2.0 / OIDC Integration
Google OAuth
Add Google OAuth login.
After authentication: create user if new, update profile if existing,
return our own JWT tokens.
Handle the OAuth callback securely.
import { OAuth2Client } from 'google-auth-library';
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.APP_URL}/auth/google/callback`
);
// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
// Generate and store state to prevent CSRF
const state = randomBytes(32).toString('hex');
req.session.oauthState = state;
const url = googleClient.generateAuthUrl({
scope: ['openid', 'email', 'profile'],
state,
access_type: 'offline', // Request refresh token
});
res.redirect(url);
});
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
// Verify CSRF state
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state — possible CSRF attack' });
}
req.session.oauthState = null;
// Exchange code for tokens
const { tokens: googleTokens } = await googleClient.getToken(code as string);
googleClient.setCredentials(googleTokens);
// Verify ID token and get user info
const ticket = await googleClient.verifyIdToken({
idToken: googleTokens.id_token!,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload()!;
// Upsert user
const user = await db.user.upsert({
where: { email: payload.email! },
update: { name: payload.name, avatarUrl: payload.picture },
create: {
email: payload.email!,
name: payload.name ?? payload.email!,
avatarUrl: payload.picture,
emailVerified: true, // Google verifies email
},
});
// Issue our own tokens
const { accessToken, refreshToken } = generateTokens(user.id);
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}&refresh=${refreshToken}`);
});
Claude includes: CSRF state validation (critical — without it, attackers can force users to log in as the attacker’s account), ID token verification (don’t skip this — verify the token, don’t just decode it), and upsert pattern for new vs. returning users.
Session Management
Server-Side Sessions
Implement session-based auth for a web app.
httpOnly cookies. Session stored in Redis.
CSRF protection with double-submit cookies.
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
name: '__sid', // Don't use default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 1 day
},
}));
name: '__sid' — renaming away from connect.sid prevents fingerprinting that reveals you’re using Express sessions. sameSite: 'strict' prevents CSRF for state-changing requests.
Role-Based Access Control
Implement RBAC.
Roles: admin, manager, member.
Permissions: per-resource per-action.
type Role = 'admin' | 'manager' | 'member';
type Resource = 'projects' | 'users' | 'billing' | 'reports';
type Action = 'read' | 'write' | 'delete' | 'admin';
const permissions: Record<Role, Record<Resource, Action[]>> = {
admin: {
projects: ['read', 'write', 'delete', 'admin'],
users: ['read', 'write', 'delete', 'admin'],
billing: ['read', 'write', 'admin'],
reports: ['read', 'write', 'admin'],
},
manager: {
projects: ['read', 'write'],
users: ['read'],
billing: ['read'],
reports: ['read', 'write'],
},
member: {
projects: ['read'],
users: [],
billing: [],
reports: ['read'],
},
};
export function hasPermission(role: Role, resource: Resource, action: Action): boolean {
return permissions[role]?.[resource]?.includes(action) ?? false;
}
// Middleware
export function requirePermission(resource: Resource, action: Action) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!hasPermission(req.user.role, resource, action)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage in routes:
router.delete('/users/:id',
requireAuth,
requirePermission('users', 'delete'),
deleteUser
);
Account Security Features
Rate Limiting Auth Endpoints
Add rate limiting to prevent brute force attacks on login.
10 attempts per IP per 15 minutes, then lockout.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
export const loginRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.sendCommand(args),
}),
handler: (req, res) => {
res.status(429).json({
error: 'Too many login attempts. Try again in 15 minutes.',
retryAfter: Math.ceil((req.rateLimit.resetTime.getTime() - Date.now()) / 1000),
});
},
});
Redis-backed rate limiting (not in-memory) works across multiple server instances. The Redis store with sendCommand is the current API for rate-limit-redis with the redis@4 client.
For the security testing side of authentication — penetration testing approaches and authentication vulnerability patterns — see the security audit guide. For the code review perspective on authentication bugs, see the code review guide. The Claude Skills 360 bundle includes authentication skill sets covering JWT patterns, OAuth integration, and session management. Start with the free tier to try the token generation and validation patterns.