| name | auth-patterns |
| description | Authentication and authorization implementation patterns: JWT, sessions (httpOnly cookies), OAuth2/OIDC, API keys, RBAC, and MFA. Covers TypeScript, Python, Go, and Java with security-hardened code examples. |
Auth Patterns Skill
Authentication (who are you?) and authorization (what can you do?) are where most security vulnerabilities live. This skill covers correct implementation — not just review.
When to Activate
- Implementing login / logout flows
- Choosing between JWT and server-side sessions
- Adding OAuth2 / social login (GitHub, Google, etc.)
- Implementing API key authentication (server-to-server)
- Adding role-based access control (RBAC)
- Implementing refresh token rotation
- Adding MFA (multi-factor authentication)
- Auditing an existing auth flow for security gaps or token leakage
Choosing the Right Auth Mechanism
@startuml
start
if (Server-to-server API?) then (yes)
:API Keys\n(X-API-Key header);
stop
else (no)
if (Third-party login\n(GitHub, Google)?) then (yes)
:OAuth2 / OIDC\n(Authorization Code + PKCE);
stop
else (no)
if (Stateless / horizontal scale\nor mobile app?) then (yes)
:JWT Access Token\n+ Refresh Token Rotation\n(store in httpOnly cookie);
stop
else (no)
:Server-Side Sessions\n(Redis-backed, httpOnly cookie);
note right
Simpler, more secure
for traditional web apps
end note
stop
endif
endif
endif
@enduml
Pattern 1: Session-Based Auth (recommended for web apps)
Simpler, more secure than JWT for traditional web applications. The server holds state; tokens can be immediately invalidated.
TypeScript (Express + Redis)
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
import bcrypt from 'bcrypt';
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: '__Host-sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}));
app.post('/api/v1/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json(problem(401, 'Invalid credentials'));
}
req.session.regenerate((err) => {
req.session.userId = user.id;
req.session.role = user.role;
res.status(200).json({ data: { id: user.id, email: user.email } });
});
});
export function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json(problem(401, 'Authentication required'));
}
next();
}
app.post('/api/v1/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('__Host-sid');
res.status(204).send();
});
});
Pattern 2: JWT Auth (stateless / mobile)
Use when: mobile apps, microservices needing stateless auth, horizontal scaling without shared session store.
Critical rules:
- Store access token in memory (JS variable), NOT localStorage (XSS risk)
- Store refresh token in httpOnly cookie
- Short-lived access tokens (15 min), long-lived refresh tokens (7 days)
- Rotate refresh tokens on every use (detect theft)
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
function issueTokens(userId: string, role: string) {
const accessToken = jwt.sign({ sub: userId, role }, ACCESS_SECRET, { expiresIn: ACCESS_TTL });
const refreshToken = jwt.sign({ sub: userId, jti: randomBytes(16).toString('hex') }, REFRESH_SECRET, { expiresIn: REFRESH_TTL });
return { accessToken, refreshToken };
}
app.post('/api/v1/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
const { accessToken, refreshToken } = issueTokens(user.id, user.role);
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/v1/auth/refresh',
});
res.json({ data: { access_token: accessToken, expires_in: 900 } });
});
app.post('/api/v1/auth/refresh', async (req, res) => {
const token = req.cookies.refresh_token;
if (!token) return res.status(401).json(problem(401, 'No refresh token'));
try {
const payload = jwt.verify(token, REFRESH_SECRET) as jwt.JwtPayload;
const used = await redis.get(`refresh:used:${payload.jti}`);
if (used) {
await redis.set(`refresh:revoked:${payload.sub}`, '1', { EX: 7 * 24 * 3600 });
return res.status(401).json(problem(401, 'Refresh token reuse detected'));
}
await redis.set(`refresh:used:${payload.jti}`, '1', { EX: 7 * 24 * 3600 });
const user = await User.findById(payload.sub);
const { accessToken, refreshToken: newRefresh } = issueTokens(user.id, user.role);
res.cookie('refresh_token', newRefresh, { httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, path: '/api/v1/auth/refresh' });
res.json({ data: { access_token: accessToken, expires_in: 900 } });
} catch {
res.status(401).json(problem(401, 'Invalid refresh token'));
}
});
export function requireAuth(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json(problem(401, 'Missing token'));
try {
req.user = jwt.verify(token, ACCESS_SECRET) as jwt.JwtPayload;
next();
} catch {
res.status(401).json(problem(401, 'Invalid token'));
}
}
Pattern 3: OAuth2 / OIDC (Social Login)
import { randomBytes } from 'crypto';
app.get('/api/v1/auth/github', (req, res) => {
const state = randomBytes(16).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GITHUB_CLIENT_ID,
redirect_uri: `${process.env.APP_URL}/api/v1/auth/github/callback`,
scope: 'read:user user:email',
state,
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
app.get('/api/v1/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(400).json(problem(400, 'Invalid OAuth state'));
}
delete req.session.oauthState;
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await tokenRes.json();
const profileRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}`, 'User-Agent': 'MyApp' },
});
const profile = await profileRes.json();
let user = await User.findByGithubId(profile.id);
if (!user) {
user = await User.create({
githubId: profile.id,
name: profile.name ?? profile.login,
avatarUrl: profile.avatar_url,
});
}
req.session.regenerate(() => {
req.session.userId = user.id;
res.redirect(process.env.APP_URL + '/dashboard');
});
});
Pattern 4: API Keys (Server-to-Server)
import { createHash, timingSafeEqual } from 'crypto';
async function createApiKey(userId: string) {
const key = `sk_live_${randomBytes(32).toString('base64url')}`;
const keyHash = createHash('sha256').update(key).digest('hex');
const prefix = key.substring(0, 12);
await db.insert(apiKeys).values({ userId, keyHash, prefix });
return key;
}
export async function apiKeyAuth(req, res, next) {
const key = req.headers['x-api-key'];
if (!key) return res.status(401).json(problem(401, 'Missing API key'));
const keyHash = createHash('sha256').update(key).digest('hex');
const apiKey = await db.query.apiKeys.findFirst({
where: eq(apiKeys.keyHash, keyHash),
});
if (!apiKey) {
timingSafeEqual(Buffer.from(keyHash), Buffer.from(keyHash));
return res.status(401).json(problem(401, 'Invalid API key'));
}
await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, apiKey.id));
req.userId = apiKey.userId;
next();
}
Pattern 5: RBAC (Role-Based Access Control)
This section covers flat role-based permission checks. For full RBAC implementation patterns (attribute-based access, hierarchical roles, policy engines like OPA/Casbin) → see kubernetes-patterns for K8s RBAC, or consider a dedicated rbac-patterns skill for application-level RBAC.
const permissions = {
admin: ['users:read', 'users:write', 'orders:read', 'orders:write', 'orders:delete'],
manager: ['orders:read', 'orders:write'],
customer: ['orders:read'],
} as const;
type Permission = typeof permissions[keyof typeof permissions][number];
export function requirePermission(permission: Permission) {
return (req, res, next) => {
const userRole = req.session?.role ?? req.user?.role;
const allowed = permissions[userRole] ?? [];
if (!allowed.includes(permission)) {
return res.status(403).json(problem(403, 'Insufficient permissions'));
}
next();
};
}
app.delete('/api/v1/orders/:id',
requireAuth,
requirePermission('orders:delete'),
deleteOrderHandler,
);
Anti-Patterns
Hashing Passwords with SHA-256 or MD5
Wrong:
import { createHash } from 'crypto';
const passwordHash = createHash('sha256').update(password).digest('hex');
Correct:
import bcrypt from 'bcrypt';
const passwordHash = await bcrypt.hash(password, 12);
const valid = await bcrypt.compare(password, passwordHash);
Why: Fast hash functions (SHA, MD5) allow offline brute-force attacks to crack passwords in seconds; bcrypt/argon2id are designed to be computationally expensive.
Skipping Session Regeneration After Login
Wrong:
app.post('/api/v1/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
req.session.userId = user.id;
res.json({ data: user });
});
Correct:
app.post('/api/v1/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
req.session.regenerate((err) => {
req.session.userId = user.id;
res.json({ data: { id: user.id, email: user.email } });
});
});
Why: Reusing the pre-login session ID enables session fixation attacks, where an attacker plants a known session ID and hijacks the session after the victim logs in.
Matching OAuth Users by Email Instead of Provider ID
Wrong:
let user = await User.findByEmail(profile.email);
if (!user) user = await User.create({ email: profile.email });
Correct:
let user = await User.findByGithubId(profile.id);
if (!user) user = await User.create({ githubId: profile.id, name: profile.name });
Why: Emails are not unique across providers and can be changed; provider IDs are immutable and scoped to a single identity.
Storing Raw API Keys in the Database
Wrong:
await db.insert(apiKeys).values({ userId, key: rawKey });
Correct:
import { createHash } from 'crypto';
const keyHash = createHash('sha256').update(rawKey).digest('hex');
const prefix = rawKey.substring(0, 12);
await db.insert(apiKeys).values({ userId, keyHash, prefix });
return rawKey;
Why: A database breach exposing raw keys gives attackers immediate full access; hashed keys require brute-force that is infeasible for long random keys.
Performing RBAC Checks Only on the Frontend
Wrong:
function AdminPanel() {
if (user.role !== 'admin') return null;
return <button onClick={() => deleteUser(id)}>Delete User</button>;
}
Correct:
app.delete('/api/v1/users/:id',
requireAuth,
requirePermission('users:write'),
deleteUserHandler,
);
Why: Frontend checks are bypassed by calling the API directly; authorization must be enforced on every server-side request.
Security Hardening Checklist