Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Getting either wrong can expose your entire system. This lesson covers the patterns and pitfalls of both.
Password Hashing
Never store passwords in plaintext. Never use fast hashes like MD5 or SHA-256 for passwords. Use a purpose-built password hashing algorithm.
// VULNERABLE: storing a plain SHA-256 hash
import { createHash } from "crypto";
function hashPassword(password: string): string {
return createHash("sha256").update(password).digest("hex");
}
// Problem: SHA-256 is fast. An attacker with a GPU can try billions of
// hashes per second and crack most passwords in hours.
// SECURE: bcrypt — intentionally slow, with a built-in salt
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12; // ~250ms per hash — slow enough to resist brute force
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Other good options: Argon2 (winner of the Password Hashing Competition) and scrypt.
// Argon2 example
import argon2 from "argon2";
async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id, // Hybrid mode: resists both GPU and side-channel attacks
memoryCost: 65536, // 64 MB — makes it expensive to parallelize
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return argon2.verify(hash, password);
}
Sessions vs. JWTs
There are two common approaches to maintaining user identity after login.
Server-Side Sessions
The server stores session data and gives the client an opaque ID.
// Login: create a session
import crypto from "crypto";
const sessions = new Map<string, { userId: string; expiresAt: number }>();
async function login(userId: string): Promise<string> {
const sessionId = crypto.randomBytes(32).toString("hex");
sessions.set(sessionId, {
userId,
expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
});
return sessionId;
}
// Set the cookie
function setSessionCookie(headers: Headers, sessionId: string) {
headers.append(
"Set-Cookie",
`session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400`
);
}
// Validate on each request
function getSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId!);
return null;
}
return session;
}Pros: Easy to revoke (delete from store), minimal data in cookie. Cons: Requires server-side storage (Redis, database).
JSON Web Tokens (JWTs)
The server signs a token containing user claims. The client sends it back on each request.
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET!;
// Create a token
function createToken(userId: string, role: string): string {
return jwt.sign(
{ sub: userId, role },
JWT_SECRET,
{ expiresIn: "1h", algorithm: "HS256" }
);
}
// Verify a token
function verifyToken(token: string) {
try {
return jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });
} catch {
return null; // Expired, tampered, or invalid
}
}Pros: Stateless, no server-side storage needed. Cons: Cannot be revoked until expiry (unless you maintain a blocklist, which defeats the purpose). Larger payload in every request.
Common JWT Mistakes
// VULNERABLE: accepting "none" algorithm
// Attacker changes the header to {"alg":"none"} and strips the signature
const payload = jwt.verify(token, JWT_SECRET); // Some libraries accept "none"!
// SECURE: always specify allowed algorithms explicitly
const payload = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });
// VULNERABLE: storing JWTs in localStorage (accessible to XSS)
localStorage.setItem("token", jwt);
// SECURE: store JWTs in HttpOnly cookies (not accessible to JavaScript)
// Set via the server, not client-side code
OAuth 2.0
OAuth lets users log in with an existing identity provider (Google, GitHub) without sharing their password with your app.
// Simplified OAuth 2.0 Authorization Code flow
// Step 1: Redirect user to the provider
function getAuthUrl(): string {
const params = new URLSearchParams({
client_id: process.env.OAUTH_CLIENT_ID!,
redirect_uri: "https://yourapp.com/auth/callback",
response_type: "code",
scope: "openid email profile",
state: crypto.randomUUID(), // Prevent CSRF on the callback
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
// Step 2: Exchange the authorization code for tokens
async function handleCallback(code: string) {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
redirect_uri: "https://yourapp.com/auth/callback",
grant_type: "authorization_code",
}),
});
const { access_token, id_token } = await response.json();
// Step 3: Use the id_token to get user info, then create a session
return id_token;
}Always validate the state parameter in the callback to prevent CSRF attacks on the OAuth flow.
Role-Based Access Control (RBAC)
Authorization determines what an authenticated user can do. RBAC assigns permissions to roles, and roles to users.
// Define roles and their permissions
const PERMISSIONS = {
admin: ["read", "write", "delete", "manage_users"],
editor: ["read", "write"],
viewer: ["read"],
} as const;
type Role = keyof typeof PERMISSIONS;
// Middleware to check permissions
function requirePermission(permission: string) {
return (req: AuthenticatedRequest, res: Response, next: Function) => {
const userRole = req.user.role as Role;
const allowed = PERMISSIONS[userRole];
if (!allowed?.includes(permission as any)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
// Usage
app.delete("/posts/:id", requirePermission("delete"), async (req, res) => {
await db.query("DELETE FROM posts WHERE id = $1", [req.params.id]);
res.json({ success: true });
});Common Authorization Mistakes
// VULNERABLE: Insecure Direct Object Reference (IDOR)
// The user can change the ID in the URL to access other users' data
app.get("/api/user/:id/profile", async (req, res) => {
const profile = await db.query("SELECT * FROM profiles WHERE user_id = $1", [
req.params.id, // Anyone can request any user's profile
]);
res.json(profile);
});
// SECURE: verify the user owns the resource
app.get("/api/user/:id/profile", async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
const profile = await db.query("SELECT * FROM profiles WHERE user_id = $1", [
req.params.id,
]);
res.json(profile);
});Secure Credential Storage
// NEVER hardcode secrets in source code
const API_KEY = "sk-1234567890abcdef"; // WRONG
// CORRECT: use environment variables
const API_KEY = process.env.API_KEY;
// CORRECT: for production, use a secrets manager
// AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, etc.
# .env.local (never committed to git)
JWT_SECRET=your-random-secret-at-least-32-bytes-long
OAUTH_CLIENT_SECRET=your-oauth-secret
DATABASE_URL=postgres://user:pass@host:5432/db
# .gitignore must include:
# .env
# .env.local
# .env.*.localKey Takeaways
- Hash passwords with bcrypt or Argon2 — never SHA-256 or MD5.
- Prefer server-side sessions for easy revocation. If using JWTs, store them in HttpOnly cookies and always specify allowed algorithms.
- Use the OAuth Authorization Code flow with PKCE for third-party login.
- Implement RBAC and always verify resource ownership (prevent IDOR).
- Keep secrets in environment variables or a secrets manager, never in code.