ทุก 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 เสมอ