Auth Patterns
Decision Tree
Need authentication → What type?
├─ Server-rendered app (SSR) → Session-based auth (cookies)
├─ SPA / mobile app → JWT (access + refresh tokens)
├─ Third-party login (Google, GitHub) → OAuth 2.0 / OIDC
├─ Machine-to-machine → API keys or client credentials
└─ Internal microservice → mTLS or service tokens
JWT vs Sessions
| Factor | JWT | Sessions |
|---|---|---|
| Storage | Client-side (memory or cookie) | Server-side (DB/Redis) |
| Scalability | Stateless, no server lookup | Requires shared session store |
| Revocation | Hard (need blocklist) | Easy (delete from store) |
| Size | Larger (payload in token) | Small (just session ID) |
| Best for | APIs, SPAs, mobile | Server-rendered apps, simple auth |
Token Architecture
Login → Issue access token (short-lived: 15min) + refresh token (long-lived: 7-30 days)
API request → Send access token in Authorization header
├─ Valid → Process request
├─ Expired → Client uses refresh token to get new access token
└─ Refresh expired → User must re-authenticate
Access token: Short-lived (15 min), contains user ID + roles, sent in Authorization: Bearer <token> header.
Refresh token: Long-lived (7-30 days), stored securely (httpOnly cookie or secure storage), used ONLY to get new access tokens, rotated on use.
Password Handling
NEVER: MD5, SHA1, SHA256 for passwords (fast hash = easy to brute force)
NEVER: Store plaintext passwords
ALWAYS: bcrypt (cost factor 12+) or argon2id
ALWAYS: Salt automatically (bcrypt does this)
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12);
const valid = await bcrypt.compare(password, hash);
from passlib.hash import bcrypt
hash = bcrypt.hash(password, rounds=12)
valid = bcrypt.verify(password, hash)
RBAC (Role-Based Access Control)
User → has Roles → Roles have Permissions → Check permission on action
// Middleware pattern
function requirePermission(permission: string) {
return (req, res, next) => {
if (!req.user.permissions.includes(permission)) {
return res.status(403).json({ error: { code: 'FORBIDDEN' } });
}
next();
};
}
app.delete('/users/:id', requirePermission('users:delete'), deleteUser);
Rules:
- Check permissions server-side ALWAYS (client checks are UX only)
- Use specific permissions (
users:delete) not broad roles (admin) in code - Roles map to permission sets in config, not hardcoded
- No IDOR — verify the user owns the resource, not just that they're authenticated
Security Checklist
- Passwords hashed with bcrypt (cost 12+) or argon2id
- Access tokens short-lived (15 min max)
- Refresh tokens rotated on use
- Tokens in httpOnly cookies (not localStorage)
- CSRF protection on cookie-based auth
- Rate limiting on login/signup endpoints
- Account lockout after failed attempts
- No sensitive data in JWT payload (no passwords, secrets)
- HTTPS everywhere (no auth over HTTP)
- Logout invalidates refresh token server-side
Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
| JWT in localStorage | httpOnly cookie (XSS-proof) |
| No token expiration | Access: 15min, Refresh: 7-30 days |
| Same secret for all tokens | Separate secrets for access/refresh |
| Permissions checked client-side only | Always enforce server-side |
| Rolling your own crypto | Use bcrypt/argon2id, established JWT libs |
| No rate limiting on auth endpoints | Rate limit + account lockout |
| Refresh tokens that never rotate | Rotate on every use, detect reuse |
| Storing user roles in JWT without server check | JWT roles for UX, server check for auth |
