ในบทความนี้
- 🐕 Authentication vs Authorization — ต่างกันยังไง?
- 🔑 Password Security — ทำยังไงให้ปลอดภัย
- 🎟️ Session vs Token — เก็บ state ยังไง?
- 🌐 OAuth 2.0 — "Login with Google/GitHub"
- 🔒 Multi-Factor Authentication (MFA/2FA)
- 🛡️ Authorization — RBAC, ABAC, Permissions
- 🔐 Security Best Practices — Checklist
- 🏢 Single Sign-On (SSO) — Enterprise
- 📊 Complete Authentication Architecture — มาดูภาพรวม
- 🔑 Key Takeaways
In this post
- 🐕 Authentication vs Authorization — What's the Difference?
- 🔑 Password Security — How to Do It Safely
- 🎟️ Session vs Token — How Do You Keep State?
- 🌐 OAuth 2.0 — "Login with Google/GitHub"
- 🔒 Multi-Factor Authentication (MFA/2FA)
- 🛡️ Authorization — RBAC, ABAC, Permissions
- 🔐 Security Best Practices — Checklist
- 🏢 Single Sign-On (SSO) — Enterprise
- 📊 Complete Authentication Architecture — The Whole Picture
- 🔑 Key Takeaways
ทุก App ที่คุณใช้ทุกวันต้องรู้ว่าคุณคือใคร และควรเข้าถึงอะไรได้บ้าง — LINE (login + ดูได้แค่ chat ของตัวเอง), Banking App (OTP + ดูได้แค่บัญชีตัวเอง), GitHub (OAuth + edit ได้แค่ repo ที่มีสิทธิ์) 🔐
🚨 "99% of data breaches = authentication/authorization failures" — ระบบ auth ที่อ่อนแอ = ประตูหน้าบ้านที่เปิดค้างไว้
🐕 Authentication vs Authorization — ต่างกันยังไง?
คนส่วนใหญ่มักเข้าใจผิดว่า Authentication กับ Authorization เป็นเรื่องเดียวกัน แต่จริงๆ แล้วมันต่างกันมาก! Authentication (AuthN) คือการพิสูจน์ว่า "คุณคือใครจริงๆ" ส่วน Authorization (AuthZ) คือการกำหนดว่า "คุณทำอะไรได้บ้างหลังจากที่เรารู้แล้วว่าคุณคือใคร"
ลองนึกภาพโรงแรม — Authentication เหมือนการที่คุณต้องแสดง Passport ตอนเช็คอิน (พิสูจน์ตัวตน) และได้รับ Key Card Authorization เหมือนการที่ Key Card ตัวนั้นเปิดได้แค่ห้อง 401 ของคุณ ไม่ใช่ห้องอื่น และเข้า Pool ได้แค่เวลา 6-20 น. เท่านั้น
ทำไมต้องมีทั้งสองอย่าง? เพราะแค่รู้ว่าคุณคือใครไม่พอ — ต้องกำหนดสิทธิ์ด้วยว่าคุณเข้าถึงอะไรได้บ้าง ไม่งั้นทุกคนจะเข้าถึงทุกอย่างได้หมดแบบ Admin ซึ่งเป็นความเสี่ยงอย่างมาก
"ลงทะเบียนเช็คอิน"
"ได้ key card"
"เข้า pool ได้ตอน 6-20"
"เข้า gym ไม่ได้ (ไม่ได้จ่ายเพิ่ม)"
สิ่งสำคัญ: Authentication ต้องมาก่อน → แล้วค่อย Authorization
🔑 Password Security — ทำยังไงให้ปลอดภัย
Password Security เป็นหัวใจสำคัญของระบบ Authentication — หากทำผิด จะส่งผลร้ายต่อผู้ใช้ทุกคน การเก็บ password แบบ plaintext คือความผิดที่ร้ายแรงที่สุด เพราะหาก database รั่วไหล ผู้โจมตีจะได้ password จริงของทุกคนไปใช้ทันที
แม้แต่การใช้ Hash algorithms แบบเก่าอย่าง MD5 หรือ SHA-256 ก็ยังไม่ปลอดภัย เพราะสามารถใช้ Rainbow Tables (ตารางที่เก็บ password ยอดนิยมกับ hash value ที่ถูกคำนวณไว้แล้ว) มา crack ได้ภายในวินาที
วิธีแก้คือการใช้ algorithms ที่ออกแบบมาให้ "ช้าโดยเจตนา" อย่าง bcrypt, scrypt หรือ Argon2 เพื่อทำให้การ brute force ใช้เวลานานขึ้นมาก พร้อมกับ Salt (ข้อมูลสุ่มที่เพิ่มเข้าไปก่อน hash) เพื่อให้ password เดียวกันได้ hash ที่ต่างกันในแต่ละ user
Password Hashing — ห้ามเก็บ plaintext เด็ดขาด!
users table:
| password | |
|---|---|
| [email protected] | MySecret123 ← Plaintext! |
| [email protected] | password456 ← Plaintext! |
ถ้าโดน hack → ได้ password ทุกคนเลย 💀
MD5("MySecret123") → "8b1a9953c4611296..."
ปัญหา: Rainbow table attack ได้ง่าย
bcrypt("MySecret123", salt) → "$2b$12$LJ3m4ys3Rz.../K0Zy6VeO3Gk2..."
- Salt unique ทุก user (random)
- Slow by design (cost factor: 12 rounds)
- Rainbow tables ใช้ไม่ได้
- GPU brute-force ยากมาก
Implementation — bcrypt
// ═══════════════════════════════
// Node.js — bcrypt
// ═══════════════════════════════
import bcrypt from 'bcrypt';
// Registration — Hash password
const SALT_ROUNDS = 12; // Cost factor (10-12 for production)
async function registerUser(email: string, password: string) {
// Validate password strength
if (password.length < 8) throw new Error('Password too short');
if (!/[A-Z]/.test(password)) throw new Error('Need uppercase');
if (!/[0-9]/.test(password)) throw new Error('Need number');
// Hash with bcrypt (auto-generates salt)
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
// → "$2b$12$LJ3m4ys3Rz0KZ1u8Hn5YHe..."
// Store ONLY the hash
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, passwordHash]
);
}
// Login — Verify password
async function loginUser(email: string, password: string) {
const user = await db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (!user) throw new Error('Invalid credentials');
// bcrypt.compare handles salt extraction automatically
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) throw new Error('Invalid credentials');
// ⚠️ Same error message! Don't reveal if email exists
return user;
}
// ═══════════════════════════════
// Python — bcrypt / passlib
// ═══════════════════════════════
from passlib.hash import bcrypt
# Hash
password_hash = bcrypt.hash("MySecret123")
# → "$2b$12$LJ3m4ys3Rz..."
# Verify
is_valid = bcrypt.verify("MySecret123", password_hash)
# → True
// ═══════════════════════════════
// Python — Argon2 (recommended for new projects)
// ═══════════════════════════════
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # 64 MB
parallelism=4 # threads
)
# Hash
hash = ph.hash("MySecret123")
# → "$argon2id$v=19$m=65536,t=3,p=4$..."
# Verify
try:
ph.verify(hash, "MySecret123") # → True
except VerifyMismatchError:
print("Invalid password")
อธิบาย SALT_ROUNDS: ค่านี้กำหนดว่าจะทำ hashing กี่รอบ — ยิ่งเยอะยิ่งช้า ยิ่งปลอดภัย แต่ใช้ CPU มากขึ้น สำหรับ production แนะนำ 10-12 rounds (ประมาณ 100-300ms ต่อครั้ง) ห้ามใช้ต่ำกว่า 10!
ทำไม error message ต้องเหมือนกัน: ไม่ว่า email จะไม่มีในระบบ หรือ password ผิด ต้อง return error เดียวกัน ("Invalid credentials") เพื่อไม่ให้ attacker รู้ว่า email ไหนมีในระบบบ้าง (Username Enumeration Attack)
Password Hashing Algorithms Comparison
การเลือก password hashing algorithm ต้องพิจารณา 3 ปัจจัย: ความปลอดภัย, ความเร็ว, และ การใช้ memory Algorithms เก่าอย่าง MD5/SHA ถูกออกแบบมาให้เร็ว จึงไม่เหมาะสำหรับ password ส่วน Algorithms ใหม่ถูกออกแบบมาให้ช้า เพื่อทำให้การ brute force ยากขึ้น
| Algorithm | Speed | Memory | Security | Use Case |
|---|---|---|---|---|
| MD5 | ⚡ Fast | Low | ❌ Broken | Never for pwd |
| SHA-256 | ⚡ Fast | Low | ❌ Weak | Checksums only |
| bcrypt | 🐢 Slow | Low | ✅ Good | Most apps |
| scrypt | 🐢 Slow | 🔴 High | ✅ Better | High-security |
| Argon2id | 🐢 Slow | 🔴 High | ✅ Best | New projects |
💡 Recommendation 2026:
├── New projects → Argon2id
├── Existing projects → bcrypt (still very good)
└── Avoid → MD5, SHA-*, PBKDF2 (for passwords)
🎟️ Session vs Token — เก็บ state ยังไง?
หลังจาก Authentication สำเร็จแล้ว ระบบต้องจำได้ว่าผู้ใช้คนนี้ล็อกอินแล้ว ไม่งั้นจะต้องล็อกอินใหม่ทุก request มีวิธีเก็บ state นี้ 2 แบบหลัก: Session-based (เก็บข้อมูลฝั่ง server) และ Token-based (เก็บข้อมูลฝั่ง client)
Session-based Authentication
Session-based authentication คือวิธีแบบดั้งเดิมที่เก็บข้อมูล user ไว้ฝั่ง server (ใน database หรือ Redis) แล้วส่ง Session ID กลับไปให้ client ใน Cookie เมื่อ client ส่ง request ครั้งถัดไป server จะใช้ Session ID นี้ค้นหาข้อมูล user จาก database
Cookies สำคัญยังไง: httpOnly flag ทำให้ JavaScript อ่าน cookie ไม่ได้ (ป้องกัน XSS), secure flag ทำให้ส่งผ่าน HTTPS เท่านั้น, sameSite=strict ป้องกัน CSRF attacks การตั้งค่าเหล่านี้จึงเป็นเรื่องสำคัญมาก
// ═══════════════════════════════
// Express.js — Session-based
// ═══════════════════════════════
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: 'redis://localhost:6379' });
await 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, // ป้องกัน XSS อ่าน cookie
sameSite: 'strict', // ป้องกัน CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 hours
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
// Store user info in session
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
// Protected route
app.get('/api/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
// ... return profile
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy();
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
Token-based Authentication (JWT)
Token-based authentication ใช้ JSON Web Tokens (JWT) ซึ่งเป็นวิธีการ stateless — หมายความว่า server ไม่ต้องเก็บข้อมูล session ใดๆ เลย เพราะข้อมูลทั้งหมดอยู่ใน token ที่ client ถือไว้
ทำไมต้องมี Access + Refresh Token: Access token มีอายุสั้น (15 นาที) เพื่อลดความเสี่ยงหากโดนขโมย ส่วน Refresh token มีอายุยาว (7 วัน) แต่เก็บใน httpOnly cookie และใช้แค่สำหรับขอ access token ใหม่เท่านั้น
ข้อดีคือ server ไม่ต้อง query database ทุกครั้ง (เพราะข้อมูลอยู่ใน JWT แล้ว) ทำให้ scale ง่าย และใช้ได้กับ microservices แต่ข้อเสียคือยาก revoke token ก่อนหมดอายุ
JWT Structure — มีอะไรอยู่ข้างใน?
⚠️ Payload is NOT encrypted! Anyone can decode it. Only signature proves it's authentic.
❌ NEVER put in JWT: password, credit card, secrets
✅ OK in JWT: user_id, role, permissions
เข้าใจ JWT Structure: Header บอกว่าใช้ algorithm ไหนใน signing, Payload เก็บข้อมูล user และ timestamps, ส่วน Signature เป็นการรับรองว่าข้อมูลไม่ถูกแก้ไข
ข้อระวัง! คนไหนก็สามารถ decode Base64 ใน Payload ได้ (ลองดูได้ที่ jwt.io) ดังนั้นห้ามใส่ข้อมูลลับ เช่น password, credit card number, social security number ใน JWT โดยเด็ดขาด!
JWT Implementation
// ═══════════════════════════════
// Node.js — JWT with Access + Refresh Tokens
// ═══════════════════════════════
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET; // ใช้ env variable!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_EXPIRY = '15m'; // 15 minutes
const REFRESH_EXPIRY = '7d'; // 7 days
// ═══════════════════════════════
// Generate Tokens
// ═══════════════════════════════
function generateTokens(user) {
const accessToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
ACCESS_SECRET,
{ expiresIn: ACCESS_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: REFRESH_EXPIRY }
);
return { accessToken, refreshToken };
}
// ═══════════════════════════════
// Login Endpoint
// ═══════════════════════════════
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
const { accessToken, refreshToken } = generateTokens(user);
// Store refresh token hash in DB (for revocation)
const refreshHash = await bcrypt.hash(refreshToken, 10);
await db.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, refreshHash, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)]
);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh', // Only sent to refresh endpoint
});
res.json({ accessToken });
});
// ═══════════════════════════════
// Auth Middleware
// ═══════════════════════════════
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, ACCESS_SECRET);
req.user = decoded; // { userId, email, 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 Token Endpoint
// ═══════════════════════════════
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// Check refresh token exists in DB (not revoked)
const stored = await db.query(
'SELECT * FROM refresh_tokens WHERE user_id = $1 AND expires_at > NOW()',
[decoded.userId]
);
if (!stored.rows.length) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
// Verify against stored hash
const isValid = await bcrypt.compare(refreshToken, stored.rows[0].token_hash);
if (!isValid) return res.status(401).json({ error: 'Invalid refresh token' });
// Get fresh user data
const user = await db.query('SELECT * FROM users WHERE id = $1', [decoded.userId]);
// Issue new access token
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
ACCESS_SECRET,
{ expiresIn: ACCESS_EXPIRY }
);
res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// ═══════════════════════════════
// Logout — Revoke refresh token
// ═══════════════════════════════
app.post('/api/auth/logout', authenticate, async (req, res) => {
// Delete all refresh tokens for this user
await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user.userId]);
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
res.json({ message: 'Logged out' });
});
// ═══════════════════════════════
// Protected Route Example
// ═══════════════════════════════
app.get('/api/profile', authenticate, async (req, res) => {
const user = await db.query('SELECT * FROM users WHERE id = $1', [req.user.userId]);
res.json({ user });
});
Session vs Token — เลือกยังไง?
| Session-based | Token-based (JWT) | |
|---|---|---|
| State stored | Server (DB/Redis) | Client (token) |
| Scalability | ⚠️ Needs shared session store | ✅ Stateless (any server) |
| Revocation | ✅ Easy (delete from DB) | ⚠️ Hard (wait for expiry) |
| Mobile support | ⚠️ Cookie issues | ✅ Header-based |
| CSRF risk | ⚠️ Yes (cookie) | ✅ No |
| XSS risk | ✅ httpOnly | ⚠️ localStorage |
| Performance | ⚠️ DB lookup | ✅ No DB lookup |
| Cross-domain | ⚠️ Complex | ✅ Easy |
| Best for | Traditional web (SSR) | SPAs, APIs, mobile, microservices |
💡 Recommendation:
├── SPA + API → JWT (access 15min + refresh 7d)
├── Traditional web (SSR) → Session (Redis-backed)
├── Mobile app → JWT
└── Both → JWT for API, Session for admin panel
🌐 OAuth 2.0 — "Login with Google/GitHub"
OAuth 2.0 เป็นมาตรฐานที่ให้ผู้ใช้ล็อกอินด้วยบัญชีจากผู้ให้บริการอื่น (เช่น Google, GitHub, Facebook) โดยไม่ต้องให้ password กับเรา วิธีนี้ปลอดภัยกว่าเพราะเราไม่ต้องจัดการ password และผู้ใช้ไม่ต้องจำ password ใหม่
ทำไม OAuth ถึงปลอดภัย: ระหว่างกระบวนการ user ไม่เคยให้ password กับเรา แต่ให้กับ Google/GitHub โดยตรง เราได้รับแค่ Authorization Code ซึ่งใช้แลกกับ Access Token เพื่อเข้าถึงข้อมูลพื้นฐาน (email, name) เท่านั้น
หลัก OAuth 2.0 Authorization Code Flow เป็น flow ที่ปลอดภัยที่สุด เหมาะสำหรับ web applications แต่ต้องมี backend server เพื่อเก็บ client secret (ห้าม expose ฝั่ง frontend!) และแลก authorization code กับ access token
OAuth Database Schema: เพื่อรองรับ multiple OAuth providers ต้องออกแบบ database ให้ user หนึ่งคนสามารถมีหลาย auth methods ได้ — เช่น ล็อกอินได้ทั้งด้วย email+password, Google OAuth, และ GitHub OAuth ในบัญชีเดียวกัน
วิธีที่ดีคือมี user_auth_providers table แยกต่างหาก เก็บ mapping ระหว่าง user กับ provider ต่างๆ พร้อมทั้ง provider_id และ access/refresh tokens (ที่ต้อง encrypt ก่อนเก็บ!) วิธีนี้ยืดหยุ่นกว่าการเพิ่ม column `google_id`, `facebook_id` ใน users table
OAuth Implementation with Passport.js
// ═══════════════════════════════
// Google OAuth 2.0 — Passport.js
// ═══════════════════════════════
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/api/auth/google/callback',
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user
let user = await db.query(
'SELECT * FROM users WHERE google_id = $1 OR email = $2',
[profile.id, profile.emails[0].value]
);
if (!user.rows.length) {
// Create new user
user = await db.query(
`INSERT INTO users (google_id, email, name, avatar_url, email_verified)
VALUES ($1, $2, $3, $4, true) RETURNING *`,
[profile.id, profile.emails[0].value, profile.displayName, profile.photos[0]?.value]
);
} else if (!user.rows[0].google_id) {
// Link Google account to existing user
await db.query(
'UPDATE users SET google_id = $1, avatar_url = COALESCE(avatar_url, $2) WHERE id = $3',
[profile.id, profile.photos[0]?.value, user.rows[0].id]
);
}
done(null, user.rows[0]);
} catch (err) {
done(err, null);
}
}
));
// Routes
app.get('/api/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);
app.get('/api/auth/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
// Generate YOUR JWT
const { accessToken, refreshToken } = generateTokens(req.user);
// Redirect to frontend with token
res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
}
);
// ═══════════════════════════════
// GitHub OAuth — Passport.js
// ═══════════════════════════════
import { Strategy as GitHubStrategy } from 'passport-github2';
passport.use(new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/api/auth/github/callback',
scope: ['user:email'],
},
async (accessToken, refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value;
let user = await findOrCreateUser({
github_id: profile.id,
email: email,
name: profile.displayName || profile.username,
avatar_url: profile.photos[0]?.value,
});
done(null, user);
}
));
app.get('/api/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/api/auth/github/callback',
passport.authenticate('github', { session: false }),
(req, res) => {
const { accessToken } = generateTokens(req.user);
res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
}
);
Database Schema for OAuth
CREATE TABLE users (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100),
avatar_url VARCHAR(500),
password_hash VARCHAR(255), -- NULL ถ้า login ผ่าน OAuth only
email_verified BOOLEAN DEFAULT false,
-- OAuth provider IDs
google_id VARCHAR(255) UNIQUE,
github_id VARCHAR(255) UNIQUE,
facebook_id VARCHAR(255) UNIQUE,
role VARCHAR(20) DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Allow multiple auth methods per user
CREATE TABLE user_auth_providers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- 'google', 'github', 'email'
provider_id VARCHAR(255) NOT NULL, -- Provider's user ID
access_token TEXT, -- Encrypted!
refresh_token TEXT, -- Encrypted!
token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
🔒 Multi-Factor Authentication (MFA/2FA)
Multi-Factor Authentication (MFA) หรือ 2FA เป็นชั้นป้องกันเพิ่มเติมที่ต้องใช้ "สิ่งที่คุณรู้" (password) + "สิ่งที่คุณมี" (โทรศัพท์) แม้ว่า password จะรั่วไหล attacker ก็ยังเข้าระบบไม่ได้เพราะไม่มีโทรศัพท์ของคุณ
TOTP (Time-based One-Time Password) ทำงานโดยใช้ shared secret key ร่วมกับ current timestamp ในการสร้างรหัส 6 หลัก ที่เปลี่ยนทุก 30 วินาที ระบบนี้ทำงานแม้ไม่มีอินเทอร์เน็ต เพราะทั้ง server และ authenticator app ใช้นาฬิกาเดียวกัน
⚠️ Even if password is stolen: Attacker ยังต้องการ physical phone เพื่อเอา code!
QR Code Setup: เมื่อ user เปิด MFA ครั้งแรก server จะสร้าง secret key และแสดงเป็น QR code ที่ใส่ข้อมูล: service name, user email, และ secret key User scan QR code นี้ด้วย Google Authenticator หรือ Authy เพื่อบันทึก secret
Backup Codes สำคัญมาก! ต้องสร้าง backup codes (เช่น 10 codes แบบใช้ครั้งเดียว) ให้ user เก็บไว้ในที่ปลอดภัย เผื่อโทรศัพท์หาย หรือ authenticator app เสีย โดย backup codes เหล่านี้ต้อง hash ก่อนเก็บใน database เหมือน password
TOTP Implementation
// ═══════════════════════════════
// TOTP Implementation — speakeasy
// ═══════════════════════════════
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';
// 1. Generate secret for user
app.post('/api/auth/mfa/setup', authenticate, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp',
length: 32,
});
// Store secret temporarily (verify before enabling)
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[secret.base32, req.user.userId]
);
// Generate QR code
const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
res.json({
qrCode: qrCodeUrl, // Display to user
manualKey: secret.base32, // Manual entry fallback
});
});
// 2. Verify and enable MFA
app.post('/api/auth/mfa/verify', authenticate, async (req, res) => {
const { code } = req.body;
const user = await db.query(
'SELECT mfa_secret_temp FROM users WHERE id = $1',
[req.user.userId]
);
const isValid = speakeasy.totp.verify({
secret: user.rows[0].mfa_secret_temp,
encoding: 'base32',
token: code,
window: 1, // Allow 1 step before/after (±30 sec)
});
if (!isValid) {
return res.status(400).json({ error: 'Invalid code' });
}
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase()
);
// Enable MFA
await db.query(
`UPDATE users SET
mfa_enabled = true,
mfa_secret = mfa_secret_temp,
mfa_secret_temp = NULL,
mfa_backup_codes = $1
WHERE id = $2`,
[JSON.stringify(backupCodes.map(c => bcrypt.hashSync(c, 10))), req.user.userId]
);
res.json({
message: 'MFA enabled!',
backupCodes, // Show ONCE, user must save them
});
});
// 3. Login with MFA
app.post('/api/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
// Step 1: Verify password
const user = await verifyCredentials(email, password);
// Step 2: Check if MFA enabled
if (user.mfa_enabled) {
if (!mfaCode) {
return res.status(200).json({
requiresMfa: true,
message: 'Please enter your 2FA code'
});
}
// Verify TOTP code
const isValid = speakeasy.totp.verify({
secret: user.mfa_secret,
encoding: 'base32',
token: mfaCode,
window: 1,
});
if (!isValid) {
// Check backup codes
const isBackup = await verifyBackupCode(user.id, mfaCode);
if (!isBackup) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
}
}
// Step 3: Issue tokens
const tokens = generateTokens(user);
res.json(tokens);
});
🛡️ Authorization — RBAC, ABAC, Permissions
หลังจาก Authentication สำเร็จแล้ว ขั้นตอนถัดไปคือ Authorization — การกำหนดว่าผู้ใช้คนนี้ทำอะไรได้บ้าง RBAC (Role-Based Access Control) เป็นรูปแบบที่ใช้กันมากที่สุด โดยกำหนด Roles (บทบาท) แต่ละ Role มี Permissions (สิทธิ์) ต่างๆ
RBAC ทำงานยังไง: แทนที่จะกำหนดสิทธิ์ให้ user โดยตรง เรากำหนด Role ให้ user แล้ว Role นั้นจะมี permissions ชุดหนึ่ง วิธีนี้จัดการง่ายกว่า — เช่น เมื่อต้องการให้ user เป็น editor แค่เปลี่ยน role เดียว ไม่ต้องไปแก้ permissions ทีละตัว
Principle of Least Privilege: ให้สิทธิ์น้อยที่สุดที่จำเป็นต่อการทำงาน — member ไม่ควรลบ post ของคนอื่น, editor ไม่ควรจัดการ user accounts มี middleware ตรวจสอบทั้ง permission และ ownership (เจ้าของ resource)
Role-Based Access Control (RBAC)
RBAC Database Schema ที่ยืดหยุ่น: การออกแบบ database สำหรับ RBAC ต้องคิดถึงอนาคต — user อาจมีหลาย roles, role อาจมี permissions ที่ซับซ้อน ดีที่สุดคือแยก tables: `roles`, `permissions`, `role_permissions` (many-to-many), `user_roles` (many-to-many)
Middleware Pattern: ใน Express.js สร้าง middleware functions เช่น `requirePermission('posts:update')` และ `requireOwnership('user_id')` เพื่อตรวจสอบสิทธิ์ก่อนเข้าถึง route ทำให้โค้ดอ่านง่าย และมั่นใจว่าทุก endpoint ตรวจสอบสิทธิ์
RBAC Implementation
// ═══════════════════════════════
// Permission-based RBAC
// ═══════════════════════════════
const PERMISSIONS = {
admin: [
'users:read', 'users:create', 'users:update', 'users:delete',
'posts:read', 'posts:create', 'posts:update', 'posts:delete',
'settings:read', 'settings:update',
'analytics:read',
],
editor: [
'posts:read', 'posts:create', 'posts:update', 'posts:delete',
'analytics:read',
],
member: [
'posts:read', 'posts:create', // Can create own posts
],
guest: [
'posts:read', // Read-only
],
};
// Middleware: Check permission
function requirePermission(...requiredPermissions) {
return (req, res, next) => {
const userRole = req.user.role;
const userPermissions = PERMISSIONS[userRole] || [];
const hasPermission = requiredPermissions.every(
perm => userPermissions.includes(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Forbidden',
message: `Required: ${requiredPermissions.join(', ')}`,
yourRole: userRole,
});
}
next();
};
}
// Middleware: Check resource ownership
function requireOwnership(resourceField = 'user_id') {
return async (req, res, next) => {
// Admins bypass ownership check
if (req.user.role === 'admin') return next();
const resource = await db.query(
`SELECT ${resourceField} FROM ${req.resourceTable} WHERE id = $1`,
[req.params.id]
);
if (resource.rows[0]?.[resourceField] !== req.user.userId) {
return res.status(403).json({ error: 'Not your resource' });
}
next();
};
}
// ═══════════════════════════════
// Routes with RBAC
// ═══════════════════════════════
// Anyone can read posts
app.get('/api/posts',
authenticate,
requirePermission('posts:read'),
getPosts
);
// Members+ can create posts
app.post('/api/posts',
authenticate,
requirePermission('posts:create'),
createPost
);
// Editors+ can update any post, members only own posts
app.put('/api/posts/:id',
authenticate,
requirePermission('posts:update'),
requireOwnership('user_id'), // Editors bypass, members checked
updatePost
);
// Admin-only routes
app.get('/api/admin/users',
authenticate,
requirePermission('users:read'),
listUsers
);
app.delete('/api/admin/users/:id',
authenticate,
requirePermission('users:delete'),
deleteUser
);
Database Schema for RBAC (Advanced)
-- ═══════════════════════════════
-- Flexible RBAC with DB
-- ═══════════════════════════════
CREATE TABLE roles (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, -- admin, editor, member
description TEXT,
is_default BOOLEAN DEFAULT false, -- Default role for new users
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE permissions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL, -- posts:create, users:delete
description TEXT,
resource VARCHAR(50) NOT NULL, -- posts, users, settings
action VARCHAR(50) NOT NULL, -- create, read, update, delete
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(resource, action)
);
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- ═══════════════════════════════
-- Query: Get user permissions
-- ═══════════════════════════════
SELECT DISTINCT p.name as permission
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
JOIN role_permissions rp ON r.id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = $1;
🔐 Security Best Practices — Checklist
ระบบ Authentication/Authorization มี attack vectors เยอะมาก การป้องกันต้องครอบคลุมทุกด้าน: ป้องกัน brute force, SQL injection, XSS, CSRF, token theft ขาดจุดไหนจุดหนึ่ง ความปลอดภัยทั้งหมดจะพัง
Common Attacks & Prevention
Rate Limiting: endpoint ต่างๆ ต้องมี rate limit ที่เหมาะสม — login endpoint เข้มงวดที่สุด (5 ครั้ง/15นาที) เพราะเป็นเป้าหมายของ brute force, API endpoints ทั่วไปใช้ 100 requests/15นาที, password reset ต้องจำกัดเพื่อป้องกัน DoS
Input Validation: validate ทุก input ทั้ง type, length, format ใช้ whitelist แทน blacklist — เช่น email ต้องตรง regex pattern, password ต้องมี uppercase/lowercase/number/special char
Security Headers ด้วย Helmet: Helmet.js ตั้งค่า security headers ที่สำคัญ — Content Security Policy (CSP) ป้องกัน XSS โดยจำกัดแหล่ง script ที่ browser จะรัน, HSTS บังคับใช้ HTTPS, X-Frame-Options ป้องกัน clickjacking
CORS Configuration: ตั้งค่า CORS ให้เข้มงวด — ระบุ origin ที่อนุญาตชัดเจน (ไม่ใช้ wildcard *), กำหนด allowed headers, credentials: true สำหรับ cookies แต่ต้องระวัง preflight cache
Rate Limiting
// ═══════════════════════════════
// Rate Limiting — express-rate-limit
// ═══════════════════════════════
import rateLimit from 'express-rate-limit';
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', apiLimiter);
// Strict login rate limit
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 login attempts
skipSuccessfulRequests: true,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
app.use('/api/auth/login', loginLimiter);
// Password reset rate limit
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 reset requests per hour
});
app.use('/api/auth/reset-password', resetLimiter);
Security Headers
// ═══════════════════════════════
// Helmet.js — Security Headers
// ═══════════════════════════════
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.myapp.com"],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// CORS configuration
import cors from 'cors';
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // Preflight cache 24h
}));
🏢 Single Sign-On (SSO) — Enterprise
SSO = เข้าสู่ระบบ 1 ครั้ง → ใช้ได้ทุก app ในองค์กร
Single Sign-On เป็นมาตรฐานสำหรับองค์กรขนาดใหญ่ เพื่อให้พนักงานไม่ต้องจำ password หลายตัว และ IT team จัดการ user accounts ได้จากจุดเดียว เมื่อพนักงานลาออก ก็ปิดบัญชีครั้งเดียวแล้วเข้าระบบอะไรไม่ได้ทั้งหมด
SAML vs OIDC: SAML 2.0 เป็นมาตรฐานเก่าที่ใช้ XML ซับซ้อนแต่รองรับ enterprise features ครบ ส่วน OpenID Connect (OIDC) เป็นมาตรฐานใหม่ที่สร้างบน OAuth 2.0 ใช้ JSON/JWT อ่านง่ายกว่า เหมาะสำหรับ modern applications
(Okta/Azure/Google)
Login once → All apps authenticated
| Protocol | Use Case | Format |
|---|---|---|
| SAML 2.0 | Enterprise (legacy) | XML-based |
| OIDC (OpenID Connect) | Modern SSO | JSON/JWT |
| OAuth 2.0 | API authorization | Not SSO per se |
Popular Providers: Okta, Auth0 (by Okta), Azure AD (Microsoft Entra ID), Google Workspace, Keycloak (open-source), AWS Cognito
📊 Complete Authentication Architecture — มาดูภาพรวม
ระบบ Authentication/Authorization ที่สมบูรณ์ประกอบด้วยหลายชั้น: Frontend (จัดการ UX + store tokens), API Gateway/Backend (verify tokens + enforce permissions), Database (เก็บ users, roles, sessions)
Frontend Layer: แสดง login form, จัดการ OAuth redirects, เก็บ access token ใน memory (ไม่ใช่ localStorage เพื่อป้องกัน XSS), ใช้ Axios interceptors เพิ่ม Bearer token ทุก request, handle 401 errors ด้วย automatic token refresh
API Gateway Layer: Rate limiting มาก่อน, followed by CORS + security headers, auth middleware ตรวจ JWT signature, RBAC middleware ตรวจ permissions, ถึงจะไปถึง business logic
Database Layer: users table เก็บ basic info + password hash + MFA settings, refresh_tokens table เก็บ valid tokens (for revocation), roles/permissions tables สำหรับ RBAC, login_attempts table สำหรับ track brute force
🔑 Key Takeaways
-
1Authentication ≠ Authorization → AuthN = "คุณคือใคร?" (ตัวตน), AuthZ = "คุณทำอะไรได้?" (สิทธิ์) — ต้องทำทั้ง 2 อย่าง ขาดอันไหนระบบไม่สมบูรณ์
-
2bcrypt/Argon2 เท่านั้น → ห้ามเก็บ plaintext เด็ดขาด, ห้าม MD5/SHA (vulnerable to rainbow tables) — Argon2id ดีที่สุดในปี 2026, bcrypt ยังใช้ได้
-
3JWT = Access (15min) + Refresh (7d) → Access token สั้นเพื่อลดความเสี่ยงหากโดนขโมย, refresh token เก็บ httpOnly cookie เพื่อป้องกัน XSS
-
4Session vs Token → Traditional web (SSR) ใช้ session + Redis, SPA/API/mobile ใช้ JWT — เลือกตาม architecture และ requirements
-
5OAuth 2.0 → "Login with Google/GitHub" — ปลอดภัยกว่าเพราะไม่ต้องจัดการ password เอง, ใช้ Authorization Code flow, ต้องมี backend เก็บ client secret
-
6MFA/2FA → TOTP (Google Authenticator) + backup codes — ป้องกัน password theft, แม้ password รั่วก็ยังเข้าไม่ได้เพราะไม่มีโทรศัพท์
-
7RBAC → Role → Permissions → Middleware pattern — ตรวจสอบสิทธิ์ทุก endpoint, principle of least privilege, แยก ownership checks
-
8Rate limiting → 5 login attempts per 15 min — ป้องกัน brute force, endpoint ต่างๆ ใช้ limit ต่างกัน (login < API < reset password)
-
9Security headers → Helmet.js (CSP, HSTS, X-Frame), CORS configuration — ป้องกัน XSS, CSRF, clickjacking ตั้งค่าให้ครบถ้วน
-
10Never trust the client → Validate ทุกอย่างฝั่ง server — client-side validation แค่ UX, real validation + authorization ต้องทำฝั่ง backend เสมอ
Every app you use each day has to know who you are and what you should be allowed to reach — LINE (login + you see only your own chats), Banking App (OTP + you see only your own accounts), GitHub (OAuth + you can edit only the repos you have rights to) 🔐
🚨 "99% of data breaches = authentication/authorization failures" — a weak auth system is a front door left standing open
🐕 Authentication vs Authorization — What's the Difference?
Most people assume Authentication and Authorization are the same thing, but they are very different! Authentication (AuthN) proves "who you really are". Authorization (AuthZ) decides "what you are allowed to do once we know who you are".
Picture a hotel — Authentication is showing your passport at check-in (proving your identity) and receiving a key card. Authorization is that key card opening only room 401, not anybody else's, and letting you into the pool only between 6 a.m. and 8 p.m.
Why do you need both? Because knowing who you are is not enough — permissions have to be assigned as well, or everyone would reach everything with Admin rights, which is an enormous risk.
"Register at check-in"
"Receive a key card"
"Pool access 6 a.m.–8 p.m."
"No gym access (didn't pay for it)"
The key point: Authentication must come first → then Authorization.
🔑 Password Security — How to Do It Safely
Password security is the heart of any authentication system — get it wrong and every one of your users pays for it. Storing passwords as plaintext is the gravest mistake of all, because the moment the database leaks, the attacker walks away with everyone's real password, ready to use.
Even older hash algorithms such as MD5 or SHA-256 are not safe, because an attacker can use Rainbow Tables (tables of popular passwords paired with their pre-computed hash values) to crack them within seconds.
The fix is to use algorithms designed to be "deliberately slow" — bcrypt, scrypt or Argon2 — so that brute forcing takes far longer, together with a Salt (random data mixed in before hashing) so that the same password produces a different hash for every user.
Password Hashing — never store plaintext, ever!
users table:
| password | |
|---|---|
| [email protected] | MySecret123 ← Plaintext! |
| [email protected] | password456 ← Plaintext! |
One hack → every single password is gone 💀
MD5("MySecret123") → "8b1a9953c4611296..."
The problem: a rainbow table attack is trivial
bcrypt("MySecret123", salt) → "$2b$12$LJ3m4ys3Rz.../K0Zy6VeO3Gk2..."
- A unique salt per user (random)
- Slow by design (cost factor: 12 rounds)
- Rainbow tables are useless
- GPU brute-force is very hard
Implementation — bcrypt
// ═══════════════════════════════
// Node.js — bcrypt
// ═══════════════════════════════
import bcrypt from 'bcrypt';
// Registration — Hash password
const SALT_ROUNDS = 12; // Cost factor (10-12 for production)
async function registerUser(email: string, password: string) {
// Validate password strength
if (password.length < 8) throw new Error('Password too short');
if (!/[A-Z]/.test(password)) throw new Error('Need uppercase');
if (!/[0-9]/.test(password)) throw new Error('Need number');
// Hash with bcrypt (auto-generates salt)
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
// → "$2b$12$LJ3m4ys3Rz0KZ1u8Hn5YHe..."
// Store ONLY the hash
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, passwordHash]
);
}
// Login — Verify password
async function loginUser(email: string, password: string) {
const user = await db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (!user) throw new Error('Invalid credentials');
// bcrypt.compare handles salt extraction automatically
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) throw new Error('Invalid credentials');
// ⚠️ Same error message! Don't reveal if email exists
return user;
}
// ═══════════════════════════════
// Python — bcrypt / passlib
// ═══════════════════════════════
from passlib.hash import bcrypt
# Hash
password_hash = bcrypt.hash("MySecret123")
# → "$2b$12$LJ3m4ys3Rz..."
# Verify
is_valid = bcrypt.verify("MySecret123", password_hash)
# → True
// ═══════════════════════════════
// Python — Argon2 (recommended for new projects)
// ═══════════════════════════════
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # 64 MB
parallelism=4 # threads
)
# Hash
hash = ph.hash("MySecret123")
# → "$argon2id$v=19$m=65536,t=3,p=4$..."
# Verify
try:
ph.verify(hash, "MySecret123") # → True
except VerifyMismatchError:
print("Invalid password")
What SALT_ROUNDS means: it sets how many rounds of hashing are performed — more rounds, slower and safer, but heavier on CPU. For production, 10-12 rounds is the recommendation (roughly 100-300 ms per call). Never go below 10!
Why the error message must be identical: whether the email does not exist in the system or the password is wrong, you must return the same error ("Invalid credentials"), so an attacker cannot learn which email addresses are registered (a Username Enumeration Attack).
Password Hashing Algorithms Comparison
Choosing a password hashing algorithm means weighing three factors: security, speed, and memory use. Older algorithms such as MD5/SHA were designed to be fast, which makes them unfit for passwords; the newer ones were designed to be slow, precisely to make brute forcing harder.
| Algorithm | Speed | Memory | Security | Use Case |
|---|---|---|---|---|
| MD5 | ⚡ Fast | Low | ❌ Broken | Never for pwd |
| SHA-256 | ⚡ Fast | Low | ❌ Weak | Checksums only |
| bcrypt | 🐢 Slow | Low | ✅ Good | Most apps |
| scrypt | 🐢 Slow | 🔴 High | ✅ Better | High-security |
| Argon2id | 🐢 Slow | 🔴 High | ✅ Best | New projects |
💡 Recommendation 2026:
├── New projects → Argon2id
├── Existing projects → bcrypt (still very good)
└── Avoid → MD5, SHA-*, PBKDF2 (for passwords)
🎟️ Session vs Token — How Do You Keep State?
Once authentication succeeds, the system has to remember that this user is logged in, or they would have to log in again on every request. There are two main ways to keep that state: session-based (the data lives on the server) and token-based (the data lives on the client).
Session-based Authentication
Session-based authentication is the traditional approach: the user's data is kept on the server (in a database or in Redis) and a Session ID is returned to the client in a cookie. On the next request, the server uses that session ID to look the user's data up in the database.
Why cookie flags matter: the httpOnly flag stops JavaScript from reading the cookie (protection against XSS), the secure flag sends it over HTTPS only, and sameSite=strict blocks CSRF attacks. Getting these settings right is not optional.
// ═══════════════════════════════
// Express.js — Session-based
// ═══════════════════════════════
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: 'redis://localhost:6379' });
await 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, // Stop XSS from reading the cookie
sameSite: 'strict', // Stop CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 hours
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
// Store user info in session
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
// Protected route
app.get('/api/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
// ... return profile
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy();
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
Token-based Authentication (JWT)
Token-based authentication uses JSON Web Tokens (JWT), a stateless approach — meaning the server keeps no session data at all, because everything it needs is inside the token the client is holding.
Why you need both an access and a refresh token: the access token is short-lived (15 minutes) to limit the damage if it is stolen, while the refresh token is long-lived (7 days) but kept in an httpOnly cookie and used for one thing only — requesting a new access token.
The upside is that the server does not have to query the database on every request (the data is already in the JWT), which makes it easy to scale and a natural fit for microservices. The downside is that revoking a token before it expires is hard.
JWT Structure — What's Inside?
⚠️ Payload is NOT encrypted! Anyone can decode it. Only signature proves it's authentic.
❌ NEVER put in JWT: password, credit card, secrets
✅ OK in JWT: user_id, role, permissions
Reading the JWT structure: the header says which algorithm was used for signing, the payload carries the user data and the timestamps, and the signature certifies that none of it has been altered.
A word of warning! Anyone can Base64-decode the payload (try it at jwt.io), so never put secrets — a password, a credit card number, a social security number — inside a JWT. Not ever!
JWT Implementation
// ═══════════════════════════════
// Node.js — JWT with Access + Refresh Tokens
// ═══════════════════════════════
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET; // Use an env variable!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_EXPIRY = '15m'; // 15 minutes
const REFRESH_EXPIRY = '7d'; // 7 days
// ═══════════════════════════════
// Generate Tokens
// ═══════════════════════════════
function generateTokens(user) {
const accessToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
ACCESS_SECRET,
{ expiresIn: ACCESS_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: REFRESH_EXPIRY }
);
return { accessToken, refreshToken };
}
// ═══════════════════════════════
// Login Endpoint
// ═══════════════════════════════
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
const { accessToken, refreshToken } = generateTokens(user);
// Store refresh token hash in DB (for revocation)
const refreshHash = await bcrypt.hash(refreshToken, 10);
await db.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, refreshHash, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)]
);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh', // Only sent to refresh endpoint
});
res.json({ accessToken });
});
// ═══════════════════════════════
// Auth Middleware
// ═══════════════════════════════
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, ACCESS_SECRET);
req.user = decoded; // { userId, email, 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 Token Endpoint
// ═══════════════════════════════
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// Check refresh token exists in DB (not revoked)
const stored = await db.query(
'SELECT * FROM refresh_tokens WHERE user_id = $1 AND expires_at > NOW()',
[decoded.userId]
);
if (!stored.rows.length) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
// Verify against stored hash
const isValid = await bcrypt.compare(refreshToken, stored.rows[0].token_hash);
if (!isValid) return res.status(401).json({ error: 'Invalid refresh token' });
// Get fresh user data
const user = await db.query('SELECT * FROM users WHERE id = $1', [decoded.userId]);
// Issue new access token
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
ACCESS_SECRET,
{ expiresIn: ACCESS_EXPIRY }
);
res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// ═══════════════════════════════
// Logout — Revoke refresh token
// ═══════════════════════════════
app.post('/api/auth/logout', authenticate, async (req, res) => {
// Delete all refresh tokens for this user
await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user.userId]);
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
res.json({ message: 'Logged out' });
});
// ═══════════════════════════════
// Protected Route Example
// ═══════════════════════════════
app.get('/api/profile', authenticate, async (req, res) => {
const user = await db.query('SELECT * FROM users WHERE id = $1', [req.user.userId]);
res.json({ user });
});
Session vs Token — Which Do You Pick?
| Session-based | Token-based (JWT) | |
|---|---|---|
| State stored | Server (DB/Redis) | Client (token) |
| Scalability | ⚠️ Needs shared session store | ✅ Stateless (any server) |
| Revocation | ✅ Easy (delete from DB) | ⚠️ Hard (wait for expiry) |
| Mobile support | ⚠️ Cookie issues | ✅ Header-based |
| CSRF risk | ⚠️ Yes (cookie) | ✅ No |
| XSS risk | ✅ httpOnly | ⚠️ localStorage |
| Performance | ⚠️ DB lookup | ✅ No DB lookup |
| Cross-domain | ⚠️ Complex | ✅ Easy |
| Best for | Traditional web (SSR) | SPAs, APIs, mobile, microservices |
💡 Recommendation:
├── SPA + API → JWT (access 15min + refresh 7d)
├── Traditional web (SSR) → Session (Redis-backed)
├── Mobile app → JWT
└── Both → JWT for API, Session for admin panel
🌐 OAuth 2.0 — "Login with Google/GitHub"
OAuth 2.0 is the standard that lets users sign in with an account from another provider (Google, GitHub, Facebook and so on) without ever handing us a password. It is the safer arrangement, because we never have to manage passwords and the user never has to remember a new one.
Why OAuth is safe: throughout the process the user never gives their password to us — they give it to Google/GitHub directly. All we receive is an Authorization Code, which we exchange for an Access Token that reaches only the basics (email, name).
The core OAuth 2.0 Authorization Code Flow is the most secure of the flows and the right fit for web applications, but it does require a backend server — to hold the client secret (never expose it on the frontend!) and to exchange the authorization code for an access token.
OAuth Database Schema: to support multiple OAuth providers, design the database so that one user can hold several auth methods — signing in with email+password, Google OAuth and GitHub OAuth, all on the same account.
The good approach is a separate user_auth_providers table holding the mapping between a user and each provider, together with the provider_id and the access/refresh tokens (which must be encrypted before storage!). That is far more flexible than bolting `google_id`, `facebook_id` columns onto the users table.
OAuth Implementation with Passport.js
// ═══════════════════════════════
// Google OAuth 2.0 — Passport.js
// ═══════════════════════════════
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/api/auth/google/callback',
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user
let user = await db.query(
'SELECT * FROM users WHERE google_id = $1 OR email = $2',
[profile.id, profile.emails[0].value]
);
if (!user.rows.length) {
// Create new user
user = await db.query(
`INSERT INTO users (google_id, email, name, avatar_url, email_verified)
VALUES ($1, $2, $3, $4, true) RETURNING *`,
[profile.id, profile.emails[0].value, profile.displayName, profile.photos[0]?.value]
);
} else if (!user.rows[0].google_id) {
// Link Google account to existing user
await db.query(
'UPDATE users SET google_id = $1, avatar_url = COALESCE(avatar_url, $2) WHERE id = $3',
[profile.id, profile.photos[0]?.value, user.rows[0].id]
);
}
done(null, user.rows[0]);
} catch (err) {
done(err, null);
}
}
));
// Routes
app.get('/api/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);
app.get('/api/auth/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
// Generate YOUR JWT
const { accessToken, refreshToken } = generateTokens(req.user);
// Redirect to frontend with token
res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
}
);
// ═══════════════════════════════
// GitHub OAuth — Passport.js
// ═══════════════════════════════
import { Strategy as GitHubStrategy } from 'passport-github2';
passport.use(new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/api/auth/github/callback',
scope: ['user:email'],
},
async (accessToken, refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value;
let user = await findOrCreateUser({
github_id: profile.id,
email: email,
name: profile.displayName || profile.username,
avatar_url: profile.photos[0]?.value,
});
done(null, user);
}
));
app.get('/api/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/api/auth/github/callback',
passport.authenticate('github', { session: false }),
(req, res) => {
const { accessToken } = generateTokens(req.user);
res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
}
);
Database Schema for OAuth
CREATE TABLE users (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100),
avatar_url VARCHAR(500),
password_hash VARCHAR(255), -- NULL if the account logs in via OAuth only
email_verified BOOLEAN DEFAULT false,
-- OAuth provider IDs
google_id VARCHAR(255) UNIQUE,
github_id VARCHAR(255) UNIQUE,
facebook_id VARCHAR(255) UNIQUE,
role VARCHAR(20) DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Allow multiple auth methods per user
CREATE TABLE user_auth_providers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- 'google', 'github', 'email'
provider_id VARCHAR(255) NOT NULL, -- Provider's user ID
access_token TEXT, -- Encrypted!
refresh_token TEXT, -- Encrypted!
token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
🔒 Multi-Factor Authentication (MFA/2FA)
Multi-Factor Authentication (MFA), or 2FA, is an extra layer of defense requiring "something you know" (a password) plus "something you have" (your phone). Even if the password leaks, the attacker still cannot get in, because they do not have your phone.
TOTP (Time-based One-Time Password) works by combining a shared secret key with the current timestamp to produce a six-digit code that changes every 30 seconds. The scheme works even with no internet connection, because the server and the authenticator app are reading the same clock.
⚠️ Even if password is stolen: the attacker still needs the physical phone to read the code!
QR Code Setup: the first time a user turns MFA on, the server generates a secret key and renders it as a QR code carrying the service name, the user's email and the secret key. The user scans that QR code with Google Authenticator or Authy to save the secret.
Backup codes matter enormously! Generate backup codes (say, 10 single-use codes) for the user to keep somewhere safe, in case the phone is lost or the authenticator app breaks. Those backup codes must be hashed before they are stored in the database, exactly like a password.
TOTP Implementation
// ═══════════════════════════════
// TOTP Implementation — speakeasy
// ═══════════════════════════════
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';
// 1. Generate secret for user
app.post('/api/auth/mfa/setup', authenticate, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp',
length: 32,
});
// Store secret temporarily (verify before enabling)
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[secret.base32, req.user.userId]
);
// Generate QR code
const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
res.json({
qrCode: qrCodeUrl, // Display to user
manualKey: secret.base32, // Manual entry fallback
});
});
// 2. Verify and enable MFA
app.post('/api/auth/mfa/verify', authenticate, async (req, res) => {
const { code } = req.body;
const user = await db.query(
'SELECT mfa_secret_temp FROM users WHERE id = $1',
[req.user.userId]
);
const isValid = speakeasy.totp.verify({
secret: user.rows[0].mfa_secret_temp,
encoding: 'base32',
token: code,
window: 1, // Allow 1 step before/after (±30 sec)
});
if (!isValid) {
return res.status(400).json({ error: 'Invalid code' });
}
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
Math.random().toString(36).substring(2, 10).toUpperCase()
);
// Enable MFA
await db.query(
`UPDATE users SET
mfa_enabled = true,
mfa_secret = mfa_secret_temp,
mfa_secret_temp = NULL,
mfa_backup_codes = $1
WHERE id = $2`,
[JSON.stringify(backupCodes.map(c => bcrypt.hashSync(c, 10))), req.user.userId]
);
res.json({
message: 'MFA enabled!',
backupCodes, // Show ONCE, user must save them
});
});
// 3. Login with MFA
app.post('/api/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
// Step 1: Verify password
const user = await verifyCredentials(email, password);
// Step 2: Check if MFA enabled
if (user.mfa_enabled) {
if (!mfaCode) {
return res.status(200).json({
requiresMfa: true,
message: 'Please enter your 2FA code'
});
}
// Verify TOTP code
const isValid = speakeasy.totp.verify({
secret: user.mfa_secret,
encoding: 'base32',
token: mfaCode,
window: 1,
});
if (!isValid) {
// Check backup codes
const isBackup = await verifyBackupCode(user.id, mfaCode);
if (!isBackup) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
}
}
// Step 3: Issue tokens
const tokens = generateTokens(user);
res.json(tokens);
});
🛡️ Authorization — RBAC, ABAC, Permissions
Once Authentication has succeeded, the next step is Authorization — deciding what this particular user is allowed to do. RBAC (Role-Based Access Control) is the most widely used model: you define Roles, and each Role carries a set of Permissions.
How RBAC works: instead of granting permissions to a user directly, you grant the user a Role, and that Role carries one bundle of permissions. This is far easier to manage — when you want a user to become an editor, you change a single role instead of editing permissions one at a time.
Principle of Least Privilege: grant the smallest set of rights the work requires — a member should not be able to delete someone else's post, an editor should not be managing user accounts. Have middleware check both the permission and the ownership (who owns the resource).
Role-Based Access Control (RBAC)
A flexible RBAC database schema: designing the database for RBAC means thinking ahead — a user may hold several roles, and a role may carry complicated permissions. The best approach is separate tables: `roles`, `permissions`, `role_permissions` (many-to-many), `user_roles` (many-to-many)
Middleware Pattern: in Express.js, write middleware functions such as `requirePermission('posts:update')` and `requireOwnership('user_id')` to check the rights before the route is reached. It keeps the code readable, and it guarantees that every endpoint checks permissions.
RBAC Implementation
// ═══════════════════════════════
// Permission-based RBAC
// ═══════════════════════════════
const PERMISSIONS = {
admin: [
'users:read', 'users:create', 'users:update', 'users:delete',
'posts:read', 'posts:create', 'posts:update', 'posts:delete',
'settings:read', 'settings:update',
'analytics:read',
],
editor: [
'posts:read', 'posts:create', 'posts:update', 'posts:delete',
'analytics:read',
],
member: [
'posts:read', 'posts:create', // Can create own posts
],
guest: [
'posts:read', // Read-only
],
};
// Middleware: Check permission
function requirePermission(...requiredPermissions) {
return (req, res, next) => {
const userRole = req.user.role;
const userPermissions = PERMISSIONS[userRole] || [];
const hasPermission = requiredPermissions.every(
perm => userPermissions.includes(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Forbidden',
message: `Required: ${requiredPermissions.join(', ')}`,
yourRole: userRole,
});
}
next();
};
}
// Middleware: Check resource ownership
function requireOwnership(resourceField = 'user_id') {
return async (req, res, next) => {
// Admins bypass ownership check
if (req.user.role === 'admin') return next();
const resource = await db.query(
`SELECT ${resourceField} FROM ${req.resourceTable} WHERE id = $1`,
[req.params.id]
);
if (resource.rows[0]?.[resourceField] !== req.user.userId) {
return res.status(403).json({ error: 'Not your resource' });
}
next();
};
}
// ═══════════════════════════════
// Routes with RBAC
// ═══════════════════════════════
// Anyone can read posts
app.get('/api/posts',
authenticate,
requirePermission('posts:read'),
getPosts
);
// Members+ can create posts
app.post('/api/posts',
authenticate,
requirePermission('posts:create'),
createPost
);
// Editors+ can update any post, members only own posts
app.put('/api/posts/:id',
authenticate,
requirePermission('posts:update'),
requireOwnership('user_id'), // Editors bypass, members checked
updatePost
);
// Admin-only routes
app.get('/api/admin/users',
authenticate,
requirePermission('users:read'),
listUsers
);
app.delete('/api/admin/users/:id',
authenticate,
requirePermission('users:delete'),
deleteUser
);
Database Schema for RBAC (Advanced)
-- ═══════════════════════════════
-- Flexible RBAC with DB
-- ═══════════════════════════════
CREATE TABLE roles (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL, -- admin, editor, member
description TEXT,
is_default BOOLEAN DEFAULT false, -- Default role for new users
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE permissions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL, -- posts:create, users:delete
description TEXT,
resource VARCHAR(50) NOT NULL, -- posts, users, settings
action VARCHAR(50) NOT NULL, -- create, read, update, delete
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(resource, action)
);
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- ═══════════════════════════════
-- Query: Get user permissions
-- ═══════════════════════════════
SELECT DISTINCT p.name as permission
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
JOIN role_permissions rp ON r.id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = $1;
🔐 Security Best Practices — Checklist
An Authentication/Authorization system has a great many attack vectors, so the defense has to cover every side of it: brute force, SQL injection, XSS, CSRF, token theft. Leave one gap and the whole of your security collapses.
Common Attacks & Prevention
Rate Limiting: every endpoint needs a rate limit that suits it — the login endpoint gets the strictest one (5 attempts / 15 minutes) because it is what brute force aims at; ordinary API endpoints run at 100 requests / 15 minutes; password reset has to be capped to prevent DoS.
Input Validation: validate every input — type, length and format — and use a whitelist rather than a blacklist: an email has to match the regex pattern, a password has to carry an uppercase letter, a lowercase letter, a number and a special character.
Security Headers with Helmet: Helmet.js sets the security headers that matter — Content Security Policy (CSP) blocks XSS by restricting where the browser may load scripts from, HSTS forces HTTPS, and X-Frame-Options prevents clickjacking.
CORS Configuration: keep CORS strict — name the permitted origins explicitly (never the wildcard *), declare the allowed headers, and set credentials: true for cookies, but watch out for the preflight cache.
Rate Limiting
// ═══════════════════════════════
// Rate Limiting — express-rate-limit
// ═══════════════════════════════
import rateLimit from 'express-rate-limit';
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/', apiLimiter);
// Strict login rate limit
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 login attempts
skipSuccessfulRequests: true,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
app.use('/api/auth/login', loginLimiter);
// Password reset rate limit
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 reset requests per hour
});
app.use('/api/auth/reset-password', resetLimiter);
Security Headers
// ═══════════════════════════════
// Helmet.js — Security Headers
// ═══════════════════════════════
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.myapp.com"],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// CORS configuration
import cors from 'cors';
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // Preflight cache 24h
}));
🏢 Single Sign-On (SSO) — Enterprise
SSO = sign in once → every app in the organization lets you in
Single Sign-On is the standard for large organizations, so that staff do not have to remember several passwords and the IT team can manage user accounts from a single place. When an employee leaves, you close one account and every system is shut to them at once.
SAML vs OIDC: SAML 2.0 is the older standard, XML-based and complicated, but it covers the full set of enterprise features. OpenID Connect (OIDC) is the newer standard, built on top of OAuth 2.0, using JSON/JWT that is much easier to read — a better fit for modern applications.
(Okta/Azure/Google)
Login once → All apps authenticated
| Protocol | Use Case | Format |
|---|---|---|
| SAML 2.0 | Enterprise (legacy) | XML-based |
| OIDC (OpenID Connect) | Modern SSO | JSON/JWT |
| OAuth 2.0 | API authorization | Not SSO per se |
Popular Providers: Okta, Auth0 (by Okta), Azure AD (Microsoft Entra ID), Google Workspace, Keycloak (open-source), AWS Cognito
📊 Complete Authentication Architecture — The Whole Picture
A complete Authentication/Authorization system is built out of several layers: Frontend (handles the UX + stores tokens), API Gateway/Backend (verifies tokens + enforces permissions), Database (stores users, roles, sessions)
Frontend Layer: shows the login form, handles the OAuth redirects, keeps the access token in memory (not in localStorage, to guard against XSS), uses Axios interceptors to attach the Bearer token to every request, and handles 401 errors with an automatic token refresh.
API Gateway Layer: rate limiting comes first, followed by CORS + security headers, then the auth middleware that checks the JWT signature, then the RBAC middleware that checks permissions — only then does the request reach the business logic.
Database Layer: the users table holds the basic info + password hash + MFA settings, the refresh_tokens table holds the valid tokens (for revocation), the roles/permissions tables serve RBAC, and the login_attempts table tracks brute force.
🔑 Key Takeaways
-
1Authentication ≠ Authorization → AuthN = "who are you?" (identity), AuthZ = "what can you do?" (permissions) — you need both; drop either one and the system is incomplete
-
2bcrypt/Argon2 and nothing else → never store plaintext, never MD5/SHA (vulnerable to rainbow tables) — Argon2id is the best choice in 2026, and bcrypt is still fine
-
3JWT = Access (15min) + Refresh (7d) → the access token is short to limit the damage if it is stolen; keep the refresh token in an httpOnly cookie to protect it from XSS
-
4Session vs Token → traditional web (SSR) uses session + Redis, while SPA/API/mobile use JWT — choose by architecture and requirements
-
5OAuth 2.0 → "Login with Google/GitHub" — safer, because you never handle the password yourself; use the Authorization Code flow, and you need a backend to hold the client secret
-
6MFA/2FA → TOTP (Google Authenticator) + backup codes — it defends against password theft; even if the password leaks, they still cannot get in without the phone
-
7RBAC → Role → Permissions → the middleware pattern — check the permission on every endpoint, follow the principle of least privilege, and keep ownership checks separate
-
8Rate limiting → 5 login attempts per 15 min — it stops brute force, and different endpoints take different limits (login < API < reset password)
-
9Security headers → Helmet.js (CSP, HSTS, X-Frame) and a CORS configuration — they defend against XSS, CSRF and clickjacking, so set every one of them
-
10Never trust the client → validate everything on the server — client-side validation is UX only; the real validation and authorization must always happen on the backend