บทเรียน 49 · Day 9 · Auth

จาก Basic Auth ถึง JWT

~25 นาที · ต้องมี: บทเรียน 34 (status code) · บทเรียน 35 (HTTP header) · บทเรียน 41 (Swagger annotation — ใช้ตอนท้ายบท)

บทนี้มาจากสไลด์ Day 9 หน้า 29–34 ซึ่งไล่จากคำถามว่า Authentication ต่างจาก Authorization ยังไง → Basic Auth → JWT
สิ่งที่ผมจะทำในบทนี้คือถอดของจริงออกมาให้ดูทุกชิ้น: ถอด header ของ Basic Auth กลับเป็นรหัสผ่าน · ถอด payload ของ JWT ตัวที่อยู่ในสไลด์โดยไม่ใช้ secret เลย · แล้วลองยิงโจมตีแบบ alg=none ใส่โปรแกรมของตัวเอง
มีสามจุดที่ผลรันจริงไม่ตรงกับที่คนมักสอนกัน — r.BasicAuth() คืน ok=true ทั้งที่รหัสผ่านผิด · JWT ที่ไม่มี exp ผ่าน jwt.Parse ได้สบาย ๆ · และ secret ของ token ตัวอย่างในสไลด์ไม่ใช่ค่าที่คนส่วนใหญ่เขียนกัน ผมจะชี้ให้ดูทีละจุดพร้อมผลรัน

🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64
github.com/golang-jwt/jwt/v5 v5.3.1
openssl / base64 ของ macOS   # ใช้ตอนถอด token ด้วยมือ
swag v1.16.4                 # ใช้เฉพาะหัวข้อ 9 (Swagger)

ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่าใหม่ · cd เข้าไป · go mod init jwtcheck · go get github.com/golang-jwt/jwt/v5
หัวข้อ 2–4 (Basic Auth) ใช้แค่ standard library ไม่ต้อง go get อะไรเลย

ในบทนี้มีโปรแกรมทดสอบหกตัว อยู่คนละโฟลเดอร์ใต้ cmd/ — อย่าสับสนว่าเป็นตัวเดียวกัน:
· cmd/basic — เซิร์ฟเวอร์ Basic Auth ปลอม ๆ ที่พิมพ์ header ที่ได้รับออกมา
· cmd/mine — ถอด token ของสไลด์ · สร้าง JWT เอง · เคส error สามแบบ
· cmd/algnone — การโจมตี alg=none
· cmd/confusion — การโจมตี algorithm confusion (RS256 → HS256)
· cmd/extra — เคส exp / nbf / aud / iss / token พังรูปแบบ
· cmd/size — วัดขนาด JWT เทียบกับ session id (ใช้ในหัวข้อ 10)

เรื่องเลขในผลรัน: ตัวเลขที่โปรแกรมพิมพ์นำหน้าแต่ละบล็อก (=== 1) … --- 4.1 ---) เป็นเลขลำดับของโปรแกรม ไม่ใช่เลขหัวข้อในบทเรียน — ถ้าเห็น --- 4.1 --- อยู่ใต้หัวข้อ 7.1 ก็ไม่ได้พิมพ์ผิด

1 · Authentication ต่างจาก Authorization ยังไง

สองคำนี้ขึ้นต้นด้วย Auth เหมือนกัน ย่อเหมือนกันว่า "auth" และคนใช้ปนกันตลอด
แต่มันตอบคนละคำถาม และเกิดคนละเวลา:

Authentication (authn)Authorization (authz)
ถามว่าอะไร "คุณเป็นใคร" "คุณทำอะไรได้บ้าง"
ภาษาไทย การพิสูจน์ตัวตน การให้สิทธิ์
เทียบกับชีวิตจริง ยามดูบัตรประชาชนคุณที่ประตู — ยืนยันว่าคุณคือคนนี้จริง บัตรพนักงานของคุณรูดเข้าชั้น 3 ได้ แต่รูดเข้าห้องเซิร์ฟเวอร์ไม่ได้
เกิดเมื่อไร ก่อน — ต้องรู้ก่อนว่าเป็นใคร หลัง — พอรู้ว่าเป็นใครแล้วค่อยเช็คสิทธิ์
HTTP status ที่ตอบกลับ
(ทบทวนได้ที่ บทเรียน 34)
401 (ยังไม่รู้ว่าคุณเป็นใคร) 403 (รู้ว่าคุณเป็นใคร แต่ห้ามเข้า)
ตัวอย่างในโปรเจกต์ Wongnok ล็อกอินด้วย username/password แล้วได้ token เจ้าของสูตรอาหารแก้สูตรตัวเองได้ · คนอื่นแก้ไม่ได้
⚠️ จุดที่สับสนที่สุด — HTTP status 401 ชื่อว่า "Unauthorized" แต่มันคือเรื่อง authentication

ชื่อนี้ตั้งผิดมาตั้งแต่ HTTP/1.0 (RFC 1945 · พฤษภาคม 1996 · หัวข้อ 9.4) และแก้ไม่ได้แล้วเพราะโลกทั้งใบใช้ไปแล้ว
วิธีจำที่ไม่พลาด: ดูที่ header ที่มันตอบกลับมาคู่กัน — 401 ตอบ WWW-Authenticate มาด้วย ซึ่งแปลว่า "ยังไม่ได้พิสูจน์ตัวตน กรุณาล็อกอินด้วยวิธีนี้"
เดี๋ยวหัวข้อ 2 คุณจะเห็น header ตัวนี้ในผลรันจริง
ส่วน 403 ไม่มี header อะไรเพิ่ม เพราะไม่มีอะไรให้คุณทำ — คุณคือคุณ และคุณเข้าไม่ได้

ทั้งบทนี้อยู่ฝั่ง authentication ล้วน ๆ — เรากำลังหาวิธีที่ดีที่สุดที่เซิร์ฟเวอร์จะรู้ว่า request ที่วิ่งเข้ามาเป็นของใคร
ส่วน authorization (เช็คว่า role นี้ทำอะไรได้) เป็นเรื่องของบทถัดไป แต่มันจะโผล่มาแวบหนึ่งในหัวข้อ 5 ตอนที่เราเห็นคำว่า "admin":true อยู่ใน token

2 · Basic Auth ทำงานยังไง

Basic Auth คือวิธีล็อกอินที่เก่าที่สุดของ HTTP กติกาสั้นมาก:
ทุก request ที่ยิงไปหาเซิร์ฟเวอร์ ให้แนบ header ชื่อ Authorization ที่มีหน้าตาแบบนี้

Authorization: Basic <base64 ของสตริง "username:password">

คำว่า "ทุก request" ในประโยคข้างบนคือหัวใจของทั้งบท — ไม่ใช่แค่ตอนล็อกอินครั้งแรก แต่คือทุกครั้ง ที่คุยกับเซิร์ฟเวอร์
ผมเขียนเซิร์ฟเวอร์เล็ก ๆ ด้วย httptest (เซิร์ฟเวอร์จำลองที่รันอยู่ในหน่วยความจำ ไม่ต้องเปิดพอร์ตจริง — รายละเอียดอยู่ในบทเรียน 40) ที่พิมพ์ header ทุกอันที่ได้รับ แล้วให้ client ยิงไปสามครั้งด้วย req.SetBasicAuth("somchai", "p@ssw0rd"):

req, _ := http.NewRequest("GET", url+"/foods", nil)
req.SetBasicAuth("somchai", "p@ssw0rd")   // ฝั่ง client — ใส่ header ให้
res, err := http.DefaultClient.Do(req)

// ---- ฝั่ง server ----
user, pass, ok := r.BasicAuth()               // แกะ header กลับมาเป็น user/pass

ผลรันจริง — ดูค่า Authorization ของทั้งสาม request ให้ดี:

$ go run ./cmd/basic

>>> request #1
[server] GET /foods  Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
[client] status=200 body=hello somchai

>>> request #2
[server] GET /foods  Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
[client] status=200 body=hello somchai

>>> request #3
[server] GET /foods  Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
[client] status=200 body=hello somchai

สตริงเดิมเป๊ะ ๆ ถูกส่งซ้ำทั้งสามครั้ง — ไม่มี session ไม่มี cookie ไม่มีอะไรจำ เซิร์ฟเวอร์ไม่รู้จักคุณเลยจนกว่าคุณจะบอกใหม่ทุกครั้ง
แปลว่ารหัสผ่านของคุณเดินทางข้ามเน็ตเวิร์กทุก request ตลอดอายุการใช้งานแอป ไม่ใช่แค่ตอนล็อกอิน

🪪 เทียบให้เห็นภาพ — บัตรประชาชน กับ บัตรผู้มาติดต่อที่ล็อบบี้ตึก

เวลาไปติดต่อตึกออฟฟิศ คุณยื่นบัตรประชาชนให้ รปภ. ที่ล็อบบี้ครั้งเดียว แล้วได้บัตรผู้มาติดต่อมาห้อยคอ
หลังจากนั้นจะขึ้นลิฟต์ · เข้าห้องประชุม · เดินกลับออกมา ก็แค่แตะบัตรใบที่ห้อยคออยู่ ไม่มีใครขอดูบัตรประชาชนซ้ำทุกประตู

Basic Auth ทำตรงข้ามกับเรื่องนี้ทั้งหมด — มันยื่น "บัตรประชาชน" (username + password ตัวจริง) ให้ใหม่ทุกประตู ทุกครั้งที่ขยับ
ผลรันข้างบนคือหลักฐาน: สตริง Basic c29tY2hhaTpwQHNzdzByZA== ก้อนเดิมเป๊ะ ๆ วิ่งซ้ำครบทั้งสามรอบ ไม่มีการแลกเป็นบัตรอะไรมาถือแทนเลย

ที่ไม่เหมือนกัน: ที่ตึกจริง คุณยื่นบัตรให้คนที่ยืนตรงหน้าคุณ มองเห็นด้วยตาว่าใครดูบ้าง
แต่ header ตัวนี้ต้องเดินผ่านเราเตอร์ Wi-Fi ร้านกาแฟ · เสาสัญญาณ · เครื่องของผู้ให้บริการเน็ต อีกหลายทอดกว่าจะถึงเซิร์ฟเวอร์ — และถ้าเว็บเป็น http:// ธรรมดา ทุกทอดที่ผ่านอ่านได้หมด
หัวข้อ 3 จะพิสูจน์ให้ดูว่า "อ่านได้" ที่ว่านี่ ง่ายแค่ไหน

และเวลารหัสผิด เซิร์ฟเวอร์ตอบแบบนี้ — สังเกต header WWW-Authenticate ที่พูดถึงในหัวข้อ 1:

>>> request รหัสผิด
[server] GET /foods  Authorization: "Basic c29tY2hhaTp3cm9uZw=="
[server] r.BasicAuth() -> user="somchai" pass="wrong" ok=true
[client] status=401 WWW-Authenticate="Basic realm=\"wongnok\"" body=unauthorized
⚠️ กับดักที่ทำให้ระบบพังจริง — ok=true ทั้งที่รหัสผ่านผิด

อ่านบรรทัดกลางอีกรอบ: รหัสผ่านคือ "wrong" ซึ่งผิด แต่ ok เป็น true
เพราะ ok ของ r.BasicAuth() ไม่ได้แปลว่า "รหัสถูก" — มันแปลว่า "แกะ header สำเร็จ" เท่านั้น คือมี header Authorization · ขึ้นต้นด้วย Basic · และ base64 ข้างหลังถอดได้
ถ้าใครเขียนโค้ดแบบนี้ ระบบจะเปิดให้ใครก็ได้ที่ใส่รหัสอะไรก็ได้เข้ามา:

user, pass, ok := r.BasicAuth()
if ok {                       // ❌ ช่องโหว่เต็ม ๆ — ok ไม่ได้แปลว่ารหัสถูก
    // ให้ผ่าน
}

ที่ถูกต้องคือต้องเทียบ user กับ pass เองเสมอ และควรเทียบด้วย subtle.ConstantTimeCompare (เทียบครบทุกไบต์เสมอ ไม่หยุดกลางคัน) เพื่อไม่ให้ผู้โจมตีเดารหัสจากเวลาที่ใช้ตอบ:

user, pass, ok := r.BasicAuth()
okUser := subtle.ConstantTimeCompare([]byte(user), []byte("somchai")) == 1
okPass := subtle.ConstantTimeCompare([]byte(pass), []byte("p@ssw0rd")) == 1
if !ok || !okUser || !okPass {
    w.Header().Set("WWW-Authenticate", `Basic realm="wongnok"`)
    w.WriteHeader(http.StatusUnauthorized)   // 401
    return
}
🗣 ศัพท์ที่เพิ่งเรียนไป

ห้าคำนี้คือคำที่ dev เขาใช้เรียกของที่คุณเพิ่งเห็นในผลรัน — ใช้คำให้ตรง จะได้คำตอบที่ตรง

authentication (authn)
"คุณเป็นใคร" — ด่านแรก ตอบด้วย 401 เมื่อไม่ผ่าน · เวลาคุยกับ senior คนทำ auth เขาย่อว่า authn (อ่านว่า ออธ-เอ็น) ประโยคที่เอาไปใช้ได้เลย: "ตรงนี้มันพังที่ authn หรือ authz ครับ" ถามแบบนี้คำตอบจะแคบลงทันที ต่างจากถามว่า "ล็อกอินไม่ได้"
authorization (authz)
"คุณทำอะไรได้บ้าง" — ด่านหลัง ตอบด้วย 403 เมื่อไม่ผ่าน · ใช้คู่กับ authn เสมอ · "เคสนี้ authn ผ่านแล้วนะ ติดที่ authz" แปลว่า "รู้แล้วว่าเป็นใคร แต่สิทธิ์ไม่พอ"
credential
คำรวมของ "ของที่ใช้ยืนยันตัวตน" — คู่ username/password · token · API key นับหมด · ใช้เวลาไม่อยากเจาะจงว่าเป็นแบบไหน: "credential มันหลุดไปโผล่ใน log รึเปล่า" ครอบคลุมกว่าถามว่า "รหัสผ่านหลุดไหม"
realm
ป้ายชื่อขอบเขตที่ต้องล็อกอิน — คือคำว่า wongnok ใน WWW-Authenticate: Basic realm="wongnok" ที่คุณเพิ่งเห็นในผลรัน · มันบอกว่า credential ชุดนี้ใช้กับพื้นที่ไหนของเว็บ ไม่ได้มีผลต่อการตรวจรหัสเลย
constant-time comparison
การเทียบค่าที่ใช้เวลาเท่ากันเสมอ ไม่ว่าจะตรงตั้งแต่ตัวแรกหรือตรงหมดทุกตัว — ใน Go คือ subtle.ConstantTimeCompare · "ตรงที่เทียบ secret เนี่ย ใช้ constant-time compare รึยังครับ" เป็นคำถามที่ reviewer สาย security ถามเป็นคำถามแรก ๆ

3 · base64 ไม่ใช่การเข้ารหัส

สไลด์เขียนไว้ตรง ๆ ว่า "Base64 ไม่ใช่การเข้ารหัส — ถอดกลับได้ง่ายมาก" แต่พูดเฉย ๆ ไม่พอ ต้องเห็นด้วยตา
ก่อนอื่นนิยามให้ชัด: base64 คือวิธีเขียนข้อมูลใหม่ให้เหลือแค่ตัวอักษร 64 ตัว (A–Z a–z 0–9 + /) เกิดมาเพื่อให้ข้อมูลที่มีอักขระแปลก ๆ เดินทางผ่าน HTTP header ได้ปลอดภัย
มันไม่ต้องใช้กุญแจ ไม่มีความลับอะไรในตัวมันเลย — ใครก็แปลงกลับได้

ทายก่อนรัน — นี่คือค่าที่ผมดักได้จาก header ในหัวข้อ 2 ตรง ๆ

Authorization: Basic c29tY2hhaTpwQHNzdzByZA==

ถ้าผมเอาส่วนที่เป็นตัวหนาไปสั่ง base64 -d โดยไม่มีกุญแจอะไรเลย — จะได้อะไรออกมา?
แล้วคำถามที่สำคัญกว่า: ถ้ามีคนนั่งดักดูทราฟฟิกอยู่ที่ Wi-Fi ร้านกาแฟ เขาได้อะไรไป?

$ printf '%s' 'somchai:p@ssw0rd' | base64
c29tY2hhaTpwQHNzdzByZA==

$ echo 'c29tY2hhaTpwQHNzdzByZA==' | base64 -d
somchai:p@ssw0rd

ได้รหัสผ่านกลับมาเต็ม ๆ ในคำสั่งเดียว ไม่ต้องมีกุญแจ ไม่ต้องเดา ไม่ต้องแกะอะไร
ผมยืนยันอีกทางด้วยโปรแกรม Go ที่วัดขนาด header ทั้งอันด้วย:

Authorization: Basic c29tY2hhaTpwQHNzdzByZA==  (45 byte ทั้ง header)
decode กลับได้ทันทีโดยไม่ต้องมี key: somchai:p@ssw0rd

คนที่ดักได้ ได้ไปทั้ง username และ password ตัวจริง — ไม่ใช่แค่ "สิทธิ์เข้าระบบนี้" แต่คือรหัสผ่านของคน ๆ นั้น ซึ่งถ้าเขาใช้รหัสเดียวกันกับอีเมลหรือธนาคาร คุณเพิ่งทำให้เขาซวยทั้งชีวิตดิจิทัล
นี่คือเหตุผลที่สไลด์เขียนว่า "ต้องใช้คู่กับ HTTPS เท่านั้น" — HTTPS ต่างหากที่เข้ารหัส ตัว Basic Auth ไม่ได้เข้ารหัสอะไรเลยสักนิดเดียว

🔢 เทียบให้เห็นภาพ — เขียนเบอร์โทรด้วยเลขไทย

เบอร์ 0812345678 ถ้าเขียนใหม่เป็น ๐๘๑๒๓๔๕๖๗๘ หน้าตามันเปลี่ยนไปเลย คนที่ไม่คุ้นอาจอ่านช้าลงหน่อย
แต่ไม่มีใครเรียกสิ่งนี้ว่า "เข้ารหัส" เพราะ ไม่มีกุญแจ ไม่มีความลับอะไรอยู่ในนั้น — ตารางแปลง ๐=0 ๑=1 อยู่ในหัวคนไทยทุกคน ใครก็อ่านกลับได้

base64 คือเรื่องเดียวกันเป๊ะ — มันเปลี่ยนวิธีเขียน ไม่ได้เปลี่ยนความลับ
ตารางแปลงของมันเป็นมาตรฐานสาธารณะ และคำสั่งที่ใช้อ่านกลับ (base64 -d) ติดมากับ macOS/Linux ทุกเครื่องอยู่แล้ว ไม่ต้องลงอะไรเพิ่มด้วยซ้ำ

ที่ไม่เหมือนกัน: สามข้อ —
(1) เลขไทยยังต้องมีคนนั่งอ่าน แต่ base64 มีคำสั่งสำเร็จรูปรออยู่ เครื่องเดียวถอดเป็นล้านชุดต่อนาทีได้สบาย ๆ ·
(2) เลขไทยเขียนแล้วยาวเท่าเดิม แต่ base64 ทำให้ข้อมูลใหญ่ขึ้นราว 4/3 เท่า — somchai:p@ssw0rd ที่ยาว 16 byte กลายเป็น c29tY2hhaTpwQHNzdzByZA== ที่ยาว 24 ตัวอักษร ·
(3) การเข้ารหัสของจริงต้องมีกุญแจ ไม่มีกุญแจก็ถอดไม่ได้เลย แต่ในนิยามของ base64 ไม่มีคำว่ากุญแจอยู่แม้แต่คำเดียว

🗣 ศัพท์ที่เพิ่งเรียนไป

สองคำแรกคือคำที่คนสับสนกันมากที่สุดในเรื่องนี้ ถ้าแยกได้ คุณจะคุยเรื่อง security ได้คนละระดับเลย

encoding
"เปลี่ยนวิธีเขียนข้อมูล" — ไม่มีกุญแจ ใครก็ย้อนกลับได้ · base64 · URL-encoding (%20) · UTF-8 อยู่ในกลุ่มนี้หมด
encryption
"ล็อกข้อมูลด้วยกุญแจ" — ไม่มีกุญแจก็ถอดไม่ได้ · ประโยคที่แยกสองคำนี้ออกจากกันได้ในทีเดียว: "อันนี้มัน encode หรือ encrypt ครับ" ถ้าเขาตอบว่า encode แปลว่าถือเป็นข้อมูลเปิด ห้ามเอาความลับใส่ลงไป
base64url
base64 รุ่นที่เปลี่ยน + เป็น - · / เป็น _ และตัด = ท้ายทิ้ง เพื่อให้แปะใน URL ได้โดยไม่ต้อง escape · JWT ใช้ตัวนี้ ไม่ใช่ base64 ธรรมดา — เดี๋ยวหัวข้อ 5 จะเจอผลของการตัด = ทิ้งด้วยตัวเอง
plaintext (cleartext)
ข้อความดิบที่อ่านออกตรง ๆ ยังไม่ถูกล็อกด้วยกุญแจ · credential ของ Basic Auth ถือเป็น plaintext เต็มตัว เพราะ base64 ถอดกลับได้ในคำสั่งเดียว · "อันนี้เราส่ง password เป็น plaintext อยู่รึเปล่าครับ"
in transit
"ตอนข้อมูลกำลังเดินทาง" (คู่กับ at rest = ตอนนอนอยู่ในดิสก์) · สิ่งที่ปกป้อง in transit คือ TLS ซึ่งก็คือตัว s ใน https · "ตรงนี้เราป้องกันตอน in transit ด้วยอะไรครับ TLS อย่างเดียวใช่ไหม"

4 · ทำไม Basic Auth ไม่เหมาะกับระบบจริง

สไลด์ลิสต์ไว้สี่ข้อ ผมจะอธิบายทีละข้อว่าทำไมมันเป็นปัญหาจริง ไม่ใช่แค่ข้อเสียบนกระดาษ
และทั้งสี่ข้อมีต้นเหตุเดียวกัน ซึ่งเห็นได้จากสิ่งที่เรา decode มาแล้วในหัวข้อ 3:

Basic: somchai:p@ssw0rd            // ทั้งหมดที่ header ตัวนี้บอกได้ มีแค่ 2 field

1 · ไม่มีวันหมดอายุ

ดูสตริงข้างบนอีกรอบ — ไม่มีที่ไหนให้ใส่วันหมดอายุเลย มันมีแค่ชื่อกับรหัส คั่นด้วย : จบ
ผลคือ header ที่หลุดออกไปวันนี้ ยังใช้ได้พรุ่งนี้ · สัปดาห์หน้า · ปีหน้า ตราบใดที่รหัสผ่านยังไม่ถูกเปลี่ยน
เทียบกับบัตรเข้าออฟฟิศแบบชั่วคราวที่หมดอายุเอง 6 โมงเย็น — Basic Auth ไม่มีกลไกแบบนั้นให้ใช้

2 · Revoke ยาก

Revoke แปลว่า "ยกเลิกสิทธิ์ที่ให้ไปแล้ว" เช่นพนักงานลาออก หรือรู้ตัวว่ารหัสหลุด แล้วอยากตัดการเข้าถึงทันที
ปัญหาคือสิ่งเดียวที่ใช้ยืนยันตัวตนได้คือรหัสผ่าน ดังนั้นวิธีเดียวที่จะยกเลิกคือ เปลี่ยนรหัสผ่าน
ซึ่งลากทุกอย่างของคน ๆ นั้นไปด้วยหมด: มือถือ · แล็ปท็อป · script ที่ตั้งไว้ · CI · ตัดขาดทุกเครื่องพร้อมกัน ทั้งที่คุณอยากตัดแค่เครื่องที่หาย
ในระบบที่ออกแบบมาดี คุณควรจะ "เตะเครื่องนี้ออกเครื่องเดียว" ได้ — Basic Auth ทำไม่ได้เลย

3 · ไม่มี claims

Claim (อ่านว่า เคลม) แปลตรงตัวว่า "ข้อกล่าวอ้าง" — ในโลก auth หมายถึงข้อมูลชิ้นหนึ่งที่ token กล่าวอ้างเกี่ยวกับผู้ถือ เช่น "คนนี้ id 42" · "คนนี้เป็น admin" · "ใบนี้หมดอายุ 6 โมงเย็น"
ลองเทียบสิ่งที่ Basic Auth บอกได้ กับสิ่งที่ JWT บอกได้ (ทั้งสองบรรทัดนี้ผม decode มาจากของจริง):

Basic: somchai:p@ssw0rd            // มีแค่ 2 field ไม่มี exp ไม่มี role
JWT  : {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}

บรรทัดล่างบอกได้ว่า เป็นใคร · ชื่ออะไร · เป็น admin ไหม · ออกให้เมื่อไร ทั้งหมดนี้อยู่ในตัว token เอง
บรรทัดบนบอกได้แค่ชื่อกับรหัส — เซิร์ฟเวอร์จึงไม่มีทางรู้อะไรมากกว่านั้น นอกจากไปถามฐานข้อมูล ซึ่งพาเราไปสู่ข้อสุดท้ายพอดี

4 · ต้องเช็ค DB ทุก request

เพราะ header ไม่มีข้อมูลอะไรที่เชื่อถือได้เลย ทุก request เซิร์ฟเวอร์จึงต้อง:
1. เอา username ไปหาแถวในตาราง users → 2. เอา password ที่ได้มาเข้าฟังก์ชัน hash แล้วเทียบกับที่เก็บไว้ → 3. ค่อยตัดสินใจ
ข้อ 2 คือตัวปัญหา — ฟังก์ชัน hash รหัสผ่านที่ใช้กันจริง (เช่น bcrypt) ถูกออกแบบมาให้ช้าโดยตั้งใจ เพื่อกันคนไล่เดารหัส
ปกติจึงจงใจตั้งให้ใช้เวลาระดับหลักสิบถึงร้อยมิลลิวินาทีต่อครั้ง ซึ่งดีมากตอนล็อกอินวันละครั้ง แต่ถ้าต้องทำทุก request คุณเพิ่งแปะเวลานั้นลงบนทุกการกดปุ่มของผู้ใช้ และ CPU ของเซิร์ฟเวอร์ก็หมดไปกับการ hash แทนที่จะไปทำงานจริง

สรุปสี่ข้อให้เหลือประโยคเดียว: ปัญหาของ Basic Auth คือ ตัวมันเองไม่มีข้อมูลอะไรเลย — มันเป็นแค่รหัสผ่านที่แปะซ้ำไปเรื่อย ๆ
ทางแก้จึงชัด: ต้องมี "ใบผ่าน" ที่มีข้อมูลอยู่ในตัว ตรวจได้เองโดยไม่ต้องถาม DB และหมดอายุเองได้
ใบผ่านแบบนั้นมีชื่อว่า JWT

5 · JWT คืออะไร — ถอดของจริงในสไลด์ให้ดู

JWT ย่อมาจาก JSON Web Token (คนส่วนใหญ่อ่านว่า "จ็อต") คือสตริงยาว ๆ ก้อนหนึ่งที่ประกอบด้วย สามส่วนคั่นด้วยจุด:

header . payload . signature
ส่วนข้างในคืออะไรใครอ่านได้
header JSON บอกว่าใช้อัลกอริทึมอะไรเซ็น เช่น {"alg":"HS256","typ":"JWT"} ทุกคน
payload JSON ของ claims ทั้งหมด — id ผู้ใช้ · role · วันหมดอายุ ทุกคน ← จำข้อนี้ให้ดี
signature ลายเซ็นที่คำนวณจาก header+payload+secret อ่านได้ แต่สร้างเองไม่ได้ถ้าไม่มี secret

สองส่วนแรกเป็นแค่ JSON ที่เอาไปเขียนด้วย base64url (base64 รุ่นที่เปลี่ยน + เป็น - และ / เป็น _ เพื่อให้ใส่ใน URL ได้ และมักตัด = ท้ายทิ้ง)
ย้ำอีกครั้งจากหัวข้อ 3: base64 ไม่ใช่การเข้ารหัส — ซึ่งแปลว่าเรื่องที่กำลังจะเกิดขึ้นข้างล่างนี้เป็นเรื่องปกติ ไม่ใช่การแฮ็ก

ทายก่อนรัน — นี่คือ token ตัวที่อยู่ในสไลด์หน้า 32 ตัวเต็ม ผมแบ่งบรรทัดตามจุดให้ดูง่าย

// ส่วนที่ 1 — header
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// ส่วนที่ 2 — payload
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0
// ส่วนที่ 3 — signature
KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

ถ้าเอาส่วนที่ 2 (payload) ไป base64 decode จะเห็นอะไร?
ผมไม่มี secret ของ token นี้ · ไม่มีกุญแจอะไรเลย · มีแค่สตริงที่คุณเห็นข้างบนนี้
ลองทายสามข้อ: (ก) มันจะ decode ออกมาได้ไหม · (ข) ถ้าได้ ข้างในมีอะไร · (ค) แล้วส่วนที่ 3 (signature) มีไว้กันอะไรกันแน่?

ถอดด้วย shell ล้วน ๆ ไม่มี Go ไม่มี library:

$ echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | tr '_-' '/+' | base64 -d
{"alg":"HS256","typ":"JWT"}

$ echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0=' \
    | tr '_-' '/+' | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}

(ก) ได้ · (ข) เห็นทุก claim รวมทั้ง "admin":true
ยืนยันซ้ำด้วย Go โดยใช้ ParseUnverified ซึ่งเป็นฟังก์ชันที่ไม่รับ secret เข้าไปเลย:

claims := jwt.MapClaims{}
tok, parts, err := jwt.NewParser().ParseUnverified(demo, claims)
=== 1) ParseUnverified (ไม่ส่ง secret เข้าไปเลย) ===
err=<nil>
alg=HS256 typ=JWT
claims=map[admin:true iat:1.516239022e+09 name:John Doe sub:1234567890]
จำนวนส่วนของ token=3

err=<nil> ทั้งที่ไม่มี secret แม้แต่ตัวเดียว — และเห็น admin:true ด้วย
ส่วน iat:1516239022 คือเวลาที่ออก token นับเป็นวินาทีตั้งแต่ปี 1970 แปลงกลับด้วย date -r 1516239022 -u ได้ Thu Jan 18 01:30:22 UTC 2018

(ค) signature กันการแก้ไข ไม่ได้กันการอ่าน
ใครก็อ่าน payload ได้ แต่ถ้าแก้ตัวเลขในนั้นแล้วส่งกลับมา signature จะไม่ตรงทันที (เดี๋ยวหัวข้อ 7 พิสูจน์)
กฎที่ตามมาจึงเป็นกฎเหล็ก: ห้ามใส่รหัสผ่าน · เลขบัตรประชาชน · เลขบัตรเครดิต หรือความลับใด ๆ ลงใน JWT เด็ดขาด เพราะมันเท่ากับเขียนไว้บนกระดาษที่ทุกคนอ่านได้

⚠️ กับดักตอนถอดเอง — base64 -d ตัดตัวท้ายทิ้งเงียบ ๆ แล้วบอกว่าสำเร็จ

สังเกตว่าในคำสั่งข้างบนผมเติม = ต่อท้าย payload ก่อน decode เพราะ base64url ของ JWT ตัด = ท้ายทิ้ง แต่ base64 -d ต้องการมัน
ถ้าไม่เติม จะเกิดแบบนี้ — และมันไม่ฟ้อง error:

$ echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' | tr '_-' '/+' | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true,"iat":151623902     ← หายไป 2 ตัว!

$ echo $?
0                                ← exit code บอกว่าสำเร็จ

JSON ปิดวงเล็บไม่ครบ เลขผิดไปหนึ่งหลัก แต่ exit code เป็น 0
วิธีเช็คก่อน: ความยาวของแต่ละส่วนต้องหารด้วย 4 ลงตัว — ${#P} ของ payload นี้ได้ 91 ซึ่ง 91 % 4 = 3 แปลว่าต้องเติม = อีก 1 ตัวให้ครบ 92
(ใน Go ไม่ต้องคิดเรื่องนี้ เพราะ base64.RawURLEncoding จัดการให้แล้ว)

🎬 เทียบให้เห็นภาพ — ตั๋วหนังที่พิมพ์ทุกอย่างไว้บนหน้าตั๋ว

ตั๋วหนังใบหนึ่งพิมพ์ไว้ครบตั้งแต่แรก: ชื่อเรื่อง · โรง 5 · รอบ 19:30 · ที่นั่ง H12
ใครหยิบขึ้นมาดูก็อ่านได้หมด ไม่ต้องขอกุญแจใคร ไม่ต้องถามพนักงาน — เพราะมันไม่เคยถูกออกแบบมาให้เป็นความลับ
ตรงนี้คือ payload ของ JWT เป๊ะ ๆ — เราเพิ่งอ่าน {"sub":"1234567890","name":"John Doe","admin":true,…} ออกมาได้โดยไม่มี secret เลยสักตัว

ทีนี้ลองเอาปากกาแก้ H12 เป็น A1 (ที่นั่งดีกว่า) แล้วยื่นให้พนักงานหน้าโรง — พอสแกนแล้วมันไม่ตรง คุณก็ไม่ได้เข้า
นั่นคือหน้าที่ทั้งหมดของ signature: มันกันการแก้ ไม่ได้กันการอ่าน

ที่ไม่เหมือนกัน: สองข้อ และทั้งสองข้อสำคัญกว่าตัวการเปรียบเทียบเสียอีก —
(1) บาร์โค้ดบนตั๋วเป็นแค่การเขียนข้อมูลเดิมซ้ำอีกรอบ ใครมีเครื่องพิมพ์ก็ทำเลียนแบบได้ · แต่ signature ของ JWT คำนวณจาก secret ที่มีแต่เซิร์ฟเวอร์รู้ ไม่มี secret ก็สร้างไม่ได้เลย (หัวข้อ 7.2 จะพิสูจน์ให้ดู: แก้ไปตัวเดียวจาก 185 ตัวก็พังทันที) ·
(2) พนักงานหน้าโรงยังยิงเข้าระบบเพื่อเช็คว่าตั๋วใบนี้ถูกใช้ไปหรือยัง · แต่ JWT ตั้งใจให้ตรวจจบในตัว ไม่ถามฐานข้อมูลเลย — ซึ่งแปลว่าใบที่ถูกขโมยไปก็ยังใช้ได้จนกว่าจะหมดอายุ เรื่องนี้จะกลับมาหลอกเราในหัวข้อ 10

🗣 ศัพท์ที่เพิ่งเรียนไป

ห้าคำนี้จะโผล่ในทุกบทที่เหลือของ Day 9 และในทุกหน้า doc ของ auth ที่คุณจะอ่านต่อจากนี้

claim
"ข้อมูลหนึ่งชิ้นที่ token กล่าวอ้างเกี่ยวกับผู้ถือ" — sub · exp · admin:true แต่ละอันคือหนึ่ง claim · "เราจะเอา role ใส่เป็น claim ในตัว token เลย หรือให้ไปอ่านจาก DB ดีครับ" เป็นคำถามออกแบบที่ senior จะเข้าใจทันทีว่าคุณกำลังชั่งอะไรอยู่
payload
ส่วนที่ 2 ของ token — ก้อน JSON ที่รวม claim ทั้งหมด · ถือว่าเป็นข้อความสาธารณะเสมอ · "claim นี้อยู่ใน payload นะ แปลว่า client อ่านได้หมด โอเคไหม"
signature
ส่วนที่ 3 — ค่าที่คำนวณจาก header + payload + secret · ตอบคำถามเดียวคือ "ก้อนนี้ถูกแก้ระหว่างทางไหม" ไม่ได้ตอบว่า "ใครอ่านได้บ้าง"
HS256 (HMAC-SHA256)
วิธีเซ็นแบบกุญแจก้อนเดียว — คนเซ็นกับคนตรวจต้องถือ secret ตัวเดียวกัน · คู่เทียบคือ RS256 ที่เซ็นด้วย private key แล้วตรวจด้วย public key · "ระบบเราเซ็นด้วย HS256 หรือ RS256 ครับ" เป็นคำถามแรกเวลาต้องต่อ auth กับทีมอื่น
bearer token
"ใครถือใบนี้ = คนนั้นคือเจ้าของสิทธิ์" ไม่มีการเช็คซ้ำว่าคนถือเป็นคนเดียวกับที่ออกให้ · นี่คือที่มาของคำว่า Bearer ใน header Authorization: Bearer <token> ที่จะเจอในหัวข้อ 9 · "token เราเป็น bearer token ใช่ไหมครับ ถ้าหลุดคือจบเลย"

secret ของ token ตัวนี้ไม่ใช่ค่าที่คนส่วนใหญ่เขียนกัน

token ตัวข้างบนเป็นตัวอย่าง default ของเว็บ jwt.io ซึ่งคนก๊อปไปใช้กันทั้งโลก และตำราเก่าเกือบทุกเล่มบอกว่า secret ของมันคือ "your-256-bit-secret"
ผมลองแล้วไม่ผ่าน — ผมคำนวณ HMAC ด้วย openssl ตรง ๆ เทียบกับ signature ในสไลด์:

$ SIGNING=<header>.<payload>
$ printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$s" -binary \
    | base64 | tr '+/' '-_' | tr -d '='

your-256-bit-secret                      reGQzG3OKdoIMWLDKOZ4TICJit3EW69cQE72E2CfzRE
a-string-secret-at-least-256-bits-long   KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
target:                                  KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

ยืนยันซ้ำด้วย jwt.Parse ของ golang-jwt/jwt/v5 v5.3.1:

=== 2) ลอง secret หลายตัวกับ token ของ jwt.io ===
secret="your-256-bit-secret"                      -> FAIL: token signature is invalid: signature is invalid
secret="secret"                                   -> FAIL: token signature is invalid: signature is invalid
secret="a-string-secret-at-least-256-bits-long"   -> OK (signature ผ่าน)

📌 jwt.io เปลี่ยนค่า default ไปแล้ว ตำราที่เขียนไว้ก่อนหน้านั้นจึงล้าสมัย — ถ้าคุณลองตามตำราเก่าแล้วขึ้น signature is invalid คุณไม่ได้ทำผิด secret ต่างหากที่คนละตัว
อีกเรื่องที่ควรรู้ไว้ก่อนงงเอง: กล่องเล็ก ๆ ในสไลด์หน้า 32 เขียนว่า "alg":"RS256" แต่ token ตัวจริงที่อยู่หน้าเดียวกัน decode ออกมาได้ {"alg":"HS256","typ":"JWT"}
สองอันนี้มาจากคนละรูป ไม่ใช่ของคู่กัน — ให้เชื่อผลที่ decode ออกมา
(HS256 = เซ็นด้วย secret ก้อนเดียวที่ทั้งคนเซ็นและคนตรวจต้องมีเหมือนกัน · RS256 = เซ็นด้วย private key แล้วตรวจด้วย public key — คนละแบบกันคนละเรื่อง)

6 · สร้างและ verify JWT ด้วย golang-jwt/jwt/v5

สไลด์หน้า 33 บอกแค่บรรทัดเดียวว่าให้ลง library ตัวไหน:

$ go get github.com/golang-jwt/jwt/v5
go: added github.com/golang-jwt/jwt/v5 v5.3.1

โค้ดสร้าง token มีแค่สองบรรทัดจริง ๆ — ที่เหลือคือการเตรียม claims
ข้างล่างนี้คือ cmd/mine/main.go ฉบับย่อ (ตัดเหลือเฉพาะส่วนที่ 3 กับ 3b) แต่เป็นไฟล์เต็มที่ก๊อปทั้งก้อนไปวางแล้ว go run ได้เลย — มีทั้งบรรทัด package และ import ครบ ไม่ต้องเติมอะไรเพิ่ม:

// cmd/mine/main.go
package main

import (
    "fmt"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

var secret = []byte("a-string-secret-at-least-256-bits-long")

func keyfunc(t *jwt.Token) (any, error) { return secret, nil } // ใช้ตอน parse ในหัวข้อถัดไป

func main() {
    now := time.Now()
    mine := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "sub":  "42",                        // subject = ผู้ใช้คนนี้เป็นใคร
        "name": "somchai",
        "role": "admin",
        "iat":  now.Unix(),                 // issued at = ออกให้เมื่อไร
        "exp":  now.Add(time.Hour).Unix(), // expires = หมดอายุเมื่อไร
    })
    s, err := mine.SignedString(secret)      // secret เป็น []byte

    fmt.Println("=== 3) สร้าง JWT เอง (HS256) ===")
    fmt.Println("SignedString err =", err)
    fmt.Println("token =", s)
    fmt.Println("len   =", len(s))
}

ผลรันจริง:

=== 3) สร้าง JWT เอง (HS256) ===
SignedString err = <nil>
token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODczNzI0MjksImlhdCI6MTc4NzM2ODgyOSwibmFtZSI6InNvbWNoYWkiLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.1JpaiixbPQRhCTZEcNwFMJbEZSJVVw6xej3cSqg_nSc
len   = 185   # 185 ตัวอักษร = 185 byte เพราะ JWT เป็น ASCII ล้วน

ฝั่งตรวจ ใช้ jwt.Parse ซึ่งขอสองอย่าง: ฟังก์ชันที่คืน key (เรียกว่า keyfunc) และ รายชื่อ alg ที่เรายอมรับ
keyfunc เราประกาศไว้ในไฟล์ข้างบนแล้ว เหลือแค่เติมบรรทัดพวกนี้ต่อท้าย main() (ก้อนนี้เป็นส่วนที่เติมเข้าไป ไม่ใช่ไฟล์เดี่ยว ๆ — ไม่ต้องใส่ package/import ซ้ำ):

    back, err := jwt.Parse(s, keyfunc, jwt.WithValidMethods([]string{"HS256"}))

    fmt.Println("\n--- 3b) parse กลับ + verify ---")
    if err != nil {
        fmt.Println("FAIL", err)
        return
    }
    c := back.Claims.(jwt.MapClaims)
    fmt.Printf("OK  valid=%v claims=%v\n", back.Valid, c)
    fmt.Printf("ชนิดจริงของ c[\"exp\"] = %T\n", c["exp"])
    fmt.Printf("ชนิดจริงของ c[\"sub\"] = %T\n", c["sub"])
--- 3b) parse กลับ + verify ---
OK  valid=true claims=map[exp:1.787372429e+09 iat:1.787368829e+09 name:somchai role:admin sub:42]
ชนิดจริงของ c["exp"] = float64
ชนิดจริงของ c["sub"] = string
⚠️ MapClaims แปลงตัวเลขเป็น float64 เสมอ — เขียน .(int) เมื่อไหร่ panic เมื่อนั้น

สังเกต exp:1.787372429e+09 ในผลรัน — มันโชว์เป็นทศนิยมเพราะมันเป็น float64 จริง ๆ
สาเหตุคือ MapClaims เป็น map[string]any ที่ decode มาจาก JSON และตัวถอด JSON มาตรฐานของ Go แปลงตัวเลขทุกตัวเป็น float64 ไม่มีข้อยกเว้น

c := back.Claims.(jwt.MapClaims)

// สองบรรทัดนี้เป็น "ตัวเลือก" ให้เทียบกัน — เอาไปใช้จริงให้เลือกบรรทัดเดียว
exp := c["exp"].(int)              // ❌ panic: interface conversion: interface {} is float64, not int
exp := int64(c["exp"].(float64))   // ✅ ถูกต้อง

ส่วน sub ในตัวอย่างนี้เป็น string เพราะเราใส่เข้าไปเป็น "42" (มีเครื่องหมายคำพูด)
ทางที่ปลอดภัยกว่าคือเลิกใช้ MapClaims แล้วประกาศ struct ของตัวเองที่ฝัง jwt.RegisteredClaims — แบบนั้นชนิดจะถูกบังคับตั้งแต่ตอน compile ไม่ต้องมานั่ง cast เอง

7 · สามเคสที่ token ใช้ไม่ได้ — พร้อม error จริง

ทั้งสามเคสข้างล่างผมรันด้วยฟังก์ชันตัวเดียวกัน คือ jwt.Parse(tokenStr, keyfunc, jwt.WithValidMethods([]string{"HS256"})) แล้วพิมพ์ทั้งข้อความ error และผลของ errors.Is สามตัวออกมาเทียบกัน

7.1 · token หมดอายุ

--- 4.1 token หมดอายุ (exp ผ่านไป 1 ชม.) ---
err  = token has invalid claims: token is expired
errors.Is(err, jwt.ErrTokenExpired)          = true
errors.Is(err, jwt.ErrSignatureInvalid)      = false
errors.Is(err, jwt.ErrTokenSignatureInvalid) = false
valid= false

7.2 · signature ผิด (แก้ token ไปหนึ่งตัวอักษร)

(แก้ตัวอักษรสุดท้าย "c" -> "Q")
--- 4.2 signature ผิด ---
err  = token signature is invalid: signature is invalid
errors.Is(err, jwt.ErrTokenExpired)          = false
errors.Is(err, jwt.ErrSignatureInvalid)      = true
errors.Is(err, jwt.ErrTokenSignatureInvalid) = true
valid= false

แก้แค่ตัวอักษรเดียวจาก 185 ตัวก็พังทันที — นี่คือหลักฐานว่า signature ทำหน้าที่ของมันจริง: มันกันการแก้ไข (แต่ยังไม่ได้กันการอ่าน ตามที่พิสูจน์ไปในหัวข้อ 5)

7.3 · alg ไม่ตรงกับที่เรายอมรับ

--- 4.3 alg ไม่ตรง (token=HS256 แต่ยอมรับแค่ RS256) ---
err  = token signature is invalid: signing method HS256 is invalid
errors.Is(err, jwt.ErrTokenExpired)          = false
errors.Is(err, jwt.ErrSignatureInvalid)      = false
errors.Is(err, jwt.ErrTokenSignatureInvalid) = true
⚠️ ErrSignatureInvalid กับ ErrTokenSignatureInvalid ไม่ใช่ตัวเดียวกัน

ชื่อต่างกันแค่คำว่า Token แต่ผลรันบอกว่าคนละตัวจริง ๆ:
· เคส 7.2 (signature ผิด) — errors.Is เป็น true ทั้งคู่
· เคส 7.3 (alg ไม่ตรง) — ErrSignatureInvalid เป็น false แต่ ErrTokenSignatureInvalid เป็น true
แปลว่าถ้าโค้ดคุณเช็คด้วย ErrSignatureInvalid อย่างเดียว คุณจะไม่จับเคส alg ไม่ตรง
เหตุผลอยู่ที่คนละไฟล์กัน: ErrTokenSignatureInvalid อยู่ใน errors.go เป็น error กลางของตัว parser · ส่วน ErrSignatureInvalid อยู่ใน hmac.go เป็นของ SigningMethodHMAC โดยเฉพาะ
เคส 7.3 ถูกปฏิเสธตั้งแต่ตอนเช็ครายชื่อ alg — ยังไม่ทันเดินไปถึงตัวตรวจ HMAC เลย error ของ HMAC จึงไม่มีทางโผล่ (ถ้าวันหนึ่งคุณย้ายไป RS256 ก็จะไม่เจอ ErrSignatureInvalid อีกเลยด้วยเหตุผลเดียวกัน)
สรุปให้จำ: ใช้ jwt.ErrTokenSignatureInvalid เพราะครอบคลุมกว่า

7.4 · เคสที่สไลด์ไม่ได้พูด แต่เจอบ่อยกว่า

ผมรันเพิ่มอีกชุด เพราะสามเคสข้างบนยังไม่ครบกับที่เจอจริงตอน debug:

$ go run ./cmd/extra

nbf อยู่อนาคต (ยังใช้ไม่ได้)                         err = token has invalid claims: token is not valid yet
aud ไม่ตรง (คาด mobile-app)                          err = token has invalid claims: token has invalid audience
iss ไม่ตรง (token มี iss=evil, คาด wongnok)          err = token has invalid claims: token has invalid issuer
iss ไม่มีเลย + WithIssuer()                          err = token has invalid claims: token is missing required claim: iss claim is required
token มีแค่ 2 ส่วน (ตัด signature ทิ้ง)              err = token is malformed: token contains an invalid number of segments
token เป็นข้อความมั่ว                                err = token is malformed: token contains an invalid number of segments
token ว่าง                                           err = token is malformed: token contains an invalid number of segments
claimย่อมาจากแปลว่า
subsubjecttoken นี้พูดถึงใคร — ปกติใส่ id ผู้ใช้
iatissued atออก token ตอนไหน
expexpiration timeหมดอายุตอนไหน — หลังเวลานี้ห้ามใช้
nbfnot beforeก่อนเวลานี้ห้ามใช้ — ใช้กับ token ที่ออกล่วงหน้า
audaudiencetoken นี้ออกให้ระบบไหนใช้ — กัน token ของแอปหนึ่งไปใช้กับอีกแอป
ississuerใครเป็นคนออก token นี้

สังเกตสองอย่างจากผลรัน: token ว่าง · token มั่ว · token ขาดส่วน ได้ error เดียวกันหมด คือ invalid number of segments — ดังนั้นถ้าเจอ error นี้อย่าไปไล่หา secret ให้เสียเวลา ปัญหาอยู่ที่ตัว token ไม่ครบสามส่วน (สาเหตุยอดฮิต: ลืมตัดคำว่า "Bearer " ออกจาก header ก่อนส่งเข้า jwt.Parse)
อีกอย่าง: สังเกตสองบรรทัด iss ให้ดี — jwt.WithIssuer("wongnok") ทำงานสองหน้าที่ในตัวเดียว และให้ error คนละตัวกัน:
· ถ้า token มี iss แต่ค่าไม่ตรง → token has invalid issuer (jwt.ErrTokenInvalidIssuer)
· ถ้า token ไม่มี iss เลย → token is missing required claim: iss claim is required (jwt.ErrTokenRequiredClaimMissing) เพราะการใส่ WithIssuer() ทำให้ iss กลายเป็น claim ที่บังคับต้องมีไปด้วยเลย
ถ้าคุณเจอ invalid issuer แปลว่า token มี iss อยู่แล้ว — ไปไล่ดูว่าใครออก token ให้ ไม่ใช่ไปไล่ดูว่าลืมใส่ claim

⚠️ ข้อนี้สำคัญมาก — JWT ที่ไม่มี exp ผ่านฉลุย

เราบอกไปในหัวข้อ 4 ว่า "JWT ดีกว่า Basic Auth เพราะมีวันหมดอายุ" ประโยคนั้นจริงแค่ครึ่งเดียว — JWT รองรับ วันหมดอายุ แต่ไม่ได้บังคับ
ผมสร้าง token ที่มีแค่ sub อย่างเดียว ไม่มี exp เลย แล้ว parse ดู:

token ไม่มี exp เลย (default)                        err = <nil>
token ไม่มี exp + WithExpirationRequired()           err = token has invalid claims: token is missing required claim: exp claim is required

err = <nil> แปลว่า token ตลอดชีพ — หลุดเมื่อไหร่คือหลุดตลอดไป ซึ่งแย่กว่า Basic Auth ด้วยซ้ำ เพราะ Basic Auth ยังเปลี่ยนรหัสผ่านตัดได้
ต้องทำสองอย่างคู่กันเสมอ: ใส่ exp เองตอน sign และ ใส่ jwt.WithExpirationRequired() ตอน parse

📌 เรื่อง leeway (ค่าเผื่อนาฬิกาคลาดเคลื่อน) — เซิร์ฟเวอร์คนละเครื่องมีเวลาไม่ตรงกันเป๊ะได้ ทำให้ token ที่เพิ่งออกมาโดนตีว่าหมดอายุแล้ว
golang-jwt มี jwt.WithLeeway() ให้เผื่อได้ ค่า default คือ 0 วินาที (ไม่เผื่อเลย):

หมดอายุ 20 วิ (default ไม่มี leeway)                 err = token has invalid claims: token is expired
หมดอายุ 20 วิ + WithLeeway(60s)                      err = <nil>

เป็นดาบสองคม: มันทำให้ token ที่หมดอายุไปแล้วจริง ๆ ยังใช้ได้ต่ออีก 60 วินาที
ใส่เมื่อจำเป็นเท่านั้น และใส่ให้น้อยที่สุดเท่าที่ระบบยอมได้

8 · การโจมตี alg=none และเหตุผลที่ต้องระบุ alg ตอน parse

ย้อนกลับไปดู header ของ JWT: {"alg":"HS256","typ":"JWT"}
ช่องนี้อยู่ในตัว token ซึ่งแปลว่าผู้โจมตีเป็นคนเขียนเองได้ — แล้วถ้าเขาเขียนว่า "alg":"none" ล่ะ?
มาตรฐาน JWT มีค่า none อยู่จริง หมายถึง "token นี้ไม่มีลายเซ็น" ซึ่งถ้าเซิร์ฟเวอร์เชื่อตามนั้น ก็เท่ากับให้ผู้โจมตีพิมพ์บัตรผ่านของตัวเองได้เลย

สร้าง token ปลอมแบบนี้ง่ายมาก ไม่ต้องมี library ด้วยซ้ำ — แค่ base64 สองก้อนแล้วปิดท้ายด้วยจุดเปล่า:

evil := b64(`{"alg":"none","typ":"JWT"}`) + "." +
        b64(`{"role":"admin","sub":"42"}`) + "."   // ← ส่วนที่ 3 ว่างเปล่า
$ go run ./cmd/algnone

=== 5) alg=none attack ===
evil token = eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.

A) มี WithValidMethods([HS256]) -> err = token signature is invalid: signing method none is invalid
B) ไม่มี WithValidMethods    -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
C) คืน UnsafeAllowNoneSignatureType -> err = <nil>

ข่าวดี: golang-jwt/jwt/v5 v5.3.1 บล็อกให้ตั้งแต่ default อยู่แล้ว — ดูเคส B ที่ไม่ได้ใส่ WithValidMethods เลย มันก็ยังไม่ยอมให้ผ่าน
ทางเดียวที่ token alg=none จะทะลุเข้ามาได้ คือเคส C: คนเขียนโค้ดเองไปคืน jwt.UnsafeAllowNoneSignatureType ใน keyfunc (ชื่อมันขึ้นต้นด้วยคำว่า Unsafe อยู่แล้ว)

🧭 ตรงนี้ต้องพูดให้ตรง อย่าท่องตามกันมา — หลายที่สอนว่า "ถ้าไม่ใส่ WithValidMethods แล้ว alg=none จะทะลุ"
ในไลบรารีตัวนี้เวอร์ชันนี้ ไม่จริง (ดูเคส B)
แต่คำแนะนำให้ใส่ WithValidMethods ยังถูกอยู่ ด้วยเหตุผลอื่นสองข้อ:
1. error ชัดกว่ามาก — signing method none is invalid อ่านแล้วรู้เลยว่าใครกำลังทำอะไร เทียบกับข้อความยาว ๆ ของ default
2. มันกันการโจมตีอีกแบบที่อันตรายกว่า ซึ่งอยู่ข้างล่างนี้

algorithm confusion — เอา public key มาเซ็นแบบ HS256

Algorithm confusion คือการที่ผู้โจมตีสลับชนิดของอัลกอริทึมเพื่อหลอกให้เซิร์ฟเวอร์ เอาของที่เปิดเผยได้ มาใช้เป็นความลับ
ไอเดียคือ: ถ้าเซิร์ฟเวอร์ตั้งใจใช้ RS256 (private key เซ็น / public key ตรวจ) แล้ว public key เป็นของสาธารณะ ใครก็โหลดได้
ผู้โจมตีก็เอา public key นั้นมาใช้เป็น "secret" ของ HS256 แล้วเซ็น token เอง — ถ้าเซิร์ฟเวอร์ไม่ได้ล็อก alg ไว้ มันจะหยิบ public key ตัวเดียวกันมาตรวจ แล้วก็ตรงกันพอดี

จุดที่ต้องเห็นให้ชัดคือ "เอา public key รูปแบบไหนมาใช้เป็น secret" — ในโปรแกรมนี้ผมใช้ PEM ทั้งก้อน (ตัวเดียวกับที่เซิร์ฟเวอร์เอาไปแปะไว้ให้โหลด) ฝั่งผู้โจมตีมีแค่สามบรรทัด:
(โปรแกรมสุ่ม RSA key ใหม่ทุกครั้งที่รัน ตัว token ที่คุณได้เลยไม่เหมือนข้างล่าง — แต่สองบรรทัด error จะออกมาเหมือนกันเป๊ะ)

der, _ := x509.MarshalPKIXPublicKey(pub)                                  // public key → DER
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) // DER → PEM
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(pubPEM)
$ go run ./cmd/confusion

attacker เซ็น HS256 ด้วย public key: err=<nil>
token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.BTBZcWv8rupZ27-xkP_c6coRiTpy4nT6fwb23UrkOo4

A) ไม่มี WithValidMethods -> err = token signature is invalid: key is of invalid type: HMAC verify expects []byte
B) มี WithValidMethods([RS256]) -> err = token signature is invalid: signing method HS256 is invalid
⚠️ อ่าน error ของเคส A ให้ดี — มันรอดเพราะบังเอิญ ไม่ใช่เพราะตรวจ

เคส A รอดเพราะ keyfunc คืน *rsa.PublicKey ส่วนตัวตรวจ HMAC ต้องการ []byte ชนิดไม่ตรงกันมันเลยพัง — ไม่ได้แปลว่ามันเช็คว่า alg ผิด
แปลว่าถ้าวันหนึ่งมีคนแก้ keyfunc ให้คืน []byte (เช่นอ่าน PEM มาเป็น byte แล้วส่งดิบ ๆ) ช่องโหว่นี้เปิดทันทีโดยไม่มีอะไรเตือน
ส่วนเคส B ปฏิเสธเพราะ alg ไม่อยู่ในรายชื่อที่เรายอมรับ ตรง ๆ ซึ่งไม่ขึ้นกับชนิดของ key เลย
กฎที่ควรถือ: ใส่ jwt.WithValidMethods() ทุกครั้ง เพราะเราอยากกันโดยตั้งใจ ไม่ใช่กันโดยบังเอิญ

9 · บอก Swagger ว่า endpoint นี้ต้องใช้ JWT

สไลด์หน้า 34 เขียนสั้น ๆ ว่า "เพิ่ม Swagger annotation เกี่ยวกับ Security Definition เพื่อระบุการใช้งาน JWT"
ของชิ้นนี้เราทำไปแล้วใน บทเรียน 41 ตอนนี้แค่มาดูอีกครั้งว่ามันเชื่อมกับ JWT ตรงไหน — มันทำงานเป็นคู่ สองจุด ขาดจุดใดจุดหนึ่งก็ไม่เกิดผล

จุดที่ 1 — ประกาศ "วิธีล็อกอิน" ไว้เหนือ func main() มีที่เดียวในทั้งโปรเจกต์:

//	@securityDefinitions.apikey	BearerAuth
//	@in							header
//	@name						Authorization
//	@description				ใส่ค่าเป็น "Bearer {token}"
func main() { ... }

จุดที่ 2 — ติดป้ายบน handler ที่ต้องล็อกอิน (ใส่เฉพาะตัวที่ต้องการ):

//	@Security		BearerAuth
//	@Router			/users/{id} [get]
func (handler *Handler) FindByID(ctx *gin.Context) { ... }

สั่ง swag init (v1.16.4) แล้วเปิด docs/swagger.json ดู จะได้สองก้อนนี้คนละที่:

// ระดับเอกสาร มาจากจุดที่ 1
"securityDefinitions": {
  "BearerAuth": {
    "description": "ใส่ค่าเป็น \"Bearer {token}\"",
    "type": "apiKey",
    "name": "Authorization",
    "in": "header"
  }
}

// ระดับ endpoint มาจากจุดที่ 2 (อยู่ใต้ paths./users/{id}.get)
"security": [ { "BearerAuth": [] } ]

ผลที่เห็นบนหน้าจอ: มีปุ่ม Authorize มุมขวาบน · endpoint ที่ต้องล็อกอินมีรูปกุญแจ · ใส่ token ครั้งเดียวแล้วกด Try it out ได้ทุกอันโดยไม่ต้องเปิด Postman

⚠️ ทำไมต้องเขียน apikey ทั้งที่มันคือ Bearer JWT — และทำไมต้องพิมพ์คำว่า "Bearer " เอง

เพราะ swag v1 ออกไฟล์เป็น Swagger 2.0 (ดูค่า "swagger": "2.0" ในไฟล์) ซึ่งเป็นสเปกที่ยังไม่มี type: http / scheme: bearer — ของพวกนั้นเพิ่งมาใน OpenAPI 3
ท่าที่ทำได้ในสเปก 2.0 จึงมีแค่บอกว่า "มี header ชื่อ Authorization" ซึ่งก็คือ apiKey
ผลข้างเคียงที่ต้องบอกทีมทุกครั้ง: ในช่อง Authorize ต้องพิมพ์คำว่า Bearer (มีเว้นวรรค) นำหน้า token เอง เพราะ Swagger UI ส่งค่าที่พิมพ์ไปตรง ๆ ไม่ได้เติมให้
นั่นแหละคือเหตุผลที่บรรทัด @description เขียนว่า ใส่ค่าเป็น "Bearer {token}" — มันคือคำเตือนที่จะไปโผล่ใต้ช่องกรอกให้คนใช้เห็น

10 · JWT ไม่ได้ดีกว่าทุกเรื่อง

ก่อนจบบท ต้องบอกฝั่งที่แลกมาด้วย ไม่งั้นคุณจะเอา JWT ไปใช้ผิดที่
อย่างแรกคือขนาด — JWT พก claims ไว้ในตัว แปลว่ามันใหญ่กว่า session id มาก และมันเดินทางไปกับทุก request เหมือน Basic Auth เป๊ะ ผมวัดมาให้ดู
สามก้อนที่เอามาวัดคือ claims พวกนี้ (cmd/size เซ็นทั้งสามก้อนด้วย HS256 เหมือนกันหมด):

minimal := jwt.MapClaims{"sub": "42", "exp": exp}

typical := jwt.MapClaims{
    "sub": "42", "name": "somchai", "role": "admin",
    "iss": "wongnok", "iat": now.Unix(), "exp": exp,
}

fat := jwt.MapClaims{
    "sub": "42", "name": "สมชาย ใจดี", "iss": "wongnok",
    "roles": []string{"admin", "editor", "reviewer"},
    "perms": []string{"food:read", "food:write", "food:delete"},
    "iat": now.Unix(), "exp": exp,
}
$ go run ./cmd/size

แบบ                              byte  ตัวอย่าง
JWT minimal (sub+exp)             120  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODczNzcxNDIsInN1YiI6IjQyIn0.XJ3SsW28hIV-mK16uXjbmZJUApKMs1X11wvM6_OM2W4
JWT typical (6 claims)            207  (ตัด token ออก)
JWT fat (roles+perms+ชื่อไทย)     331  (ตัด token ออก)
session id 128-bit hex             32  954aa83869ba92dfb8c586b374c221e3
session id 256-bit b64url          43  syv3gevNPL5wqDBV7Z-J0zGCYrBegP_3_W101-LKLGE

header ที่ส่งจริง Authorization: Bearer <typical> = 229 byte
header ที่ส่งจริง Cookie: sid=<hex>              = 44 byte
typical JWT ใหญ่กว่า session hex = 6.5 เท่า
fat JWT ใหญ่กว่า session hex     = 10.3 เท่า

ถ้าคุณรันเอง exp/iat กับ session id จะเป็นค่าอื่น แต่ตัวเลข byte จะเท่ากันเป๊ะ เพราะ unix timestamp ยาว 10 หลักเท่ากันทุกครั้ง และ signature ของ HS256 ยาว 43 ตัวอักษรเสมอ

ตัวที่บวมที่สุดคือ token ที่ยัด roles + perms + ชื่อภาษาไทย
เหตุผลเรื่องภาษาไทยคือจำนวนไบต์ ไม่ใช่การ escape — encoding/json ของ Go ไม่แปลงอักษรไทยเป็น \uXXXX (มัน escape แค่ < > & และตัวคั่นบรรทัดพิเศษ U+2028/U+2029) payload จึงเก็บภาษาไทยเป็น UTF-8 ดิบ ๆ อ่านออกได้ตรง ๆ ตอน decode
แต่อักษรไทยหนึ่งตัวกิน 3 ไบต์ ใน UTF-8 (ASCII กินตัวละ 1 ไบต์) แล้ว base64 ยังขยายอีก 4/3 เท่า → ชื่อไทยหนึ่งตัวกลายเป็น 4 ตัวอักษร ใน token ขณะที่ตัวอักษรอังกฤษสามตัวถึงจะกินที่เท่ากัน
ส่วนอย่างที่สองที่หนักกว่าเรื่องขนาด: JWT ที่ออกไปแล้ว เรียกคืนไม่ได้
เพราะจุดขายทั้งหมดของมันคือ "เซิร์ฟเวอร์ตรวจได้เองโดยไม่ต้องถาม DB" — ซึ่งแปลว่าเซิร์ฟเวอร์ก็ไม่รู้เหมือนกันว่ามีใครถูกแบนไปแล้ว
ถ้าผู้ใช้ถูกไล่ออกตอน 10 โมง แต่ token ของเขาหมดอายุ 11 โมง เขายังใช้งานได้อีกชั่วโมงเต็ม

Basic AuthSession id + DBJWT
ส่งอะไรไปทุก requestรหัสผ่านตัวจริงid สุ่ม 32 byteตัว token 120–331 byte
มีวันหมดอายุในตัวไม่มีเซิร์ฟเวอร์คุมเองมี — ถ้าใส่ exp เอง
เช็ค DB ทุก requestต้อง (hash ด้วย)ต้อง (แต่แค่หา key)ไม่ต้อง
ยกเลิกทันทีได้ไหมต้องเปลี่ยนรหัสผ่านได้ — ลบแถวเดียวไม่ได้ ต้องรอหมดอายุ
พก claims (role ฯลฯ)ไม่ได้ได้ (เก็บฝั่งเซิร์ฟเวอร์)ได้ — อยู่ในตัว token

ท่ามาตรฐานที่ระบบจริงใช้กัน คือรวมข้อดีสองฝั่ง: ออก access token (JWT) ที่อายุสั้นมาก เช่น 15 นาที เอาไว้ยิง API — คู่กับ refresh token ที่อายุยาวและเก็บใน DB จริง เอาไว้ขอ access token ใบใหม่
เวลาจะแบนใคร ก็ลบ refresh token ของเขาทิ้ง — เขาใช้ได้อีกไม่เกิน 15 นาทีก็จบ
คุณยังไม่ต้องทำท่านี้ตอนนี้ แค่รู้ไว้ว่า "อายุ token สั้น" ไม่ใช่ความจู้จี้ แต่เป็น คำตอบเดียวที่ JWT มีให้กับปัญหา revoke

ทดสอบความเข้าใจ

1. ผู้ใช้ล็อกอินสำเร็จแล้ว แล้วกดปุ่มลบสูตรอาหารของคนอื่น — เซิร์ฟเวอร์ควรตอบ status อะไร และนั่นเป็นเรื่องของอะไร?

ลำดับของสองเรื่องนี้คือกุญแจ: authentication เกิดก่อน (คุณเป็นใคร) แล้ว authorization เกิดทีหลัง (คุณทำอะไรได้)
โจทย์บอกชัดว่า "ล็อกอินสำเร็จแล้ว" แปลว่าด่าน authentication ผ่านไปแล้ว เซิร์ฟเวอร์รู้ว่าเขาเป็นใคร — ที่ติดคือเรื่องสิทธิ์ล้วน ๆ ซึ่งคือ 403
ตัวเลือกที่ 1 กับ 2 ผิดเพราะ 401 แปลว่า "ยังไม่รู้ว่าคุณเป็นใคร" ซึ่งขัดกับโจทย์
โดยเฉพาะข้อ 2 ที่ฟังดูดี — WWW-Authenticate คือ header ที่มาคู่กับ 401 เพื่อบอกวิธีพิสูจน์ตัวตน (ในผลรันหัวข้อ 2 เราเห็นค่าจริงคือ WWW-Authenticate="Basic realm=\"wongnok\"") — มันไม่ใช่เครื่องมือสำหรับบอกว่า "สิทธิ์ไม่พอ"
ตัวเลือกที่ 4 ผิดเพราะ request ถูกต้องทุกอย่าง ไม่มีข้อมูลไหนพัง
จุดที่ต้องจำ: ชื่อ 401 Unauthorized ตั้งผิดมาตั้งแต่ต้น — มันหมายถึง unauthenticated ส่วนตัวที่แปลว่า "ไม่มีสิทธิ์" จริง ๆ คือ 403 Forbidden

2. โค้ดเซิร์ฟเวอร์เขียนว่า user, pass, ok := r.BasicAuth() แล้วตามด้วย if ok { ให้ผ่าน } — เกิดอะไรขึ้น?

ผลรันจริงที่ยิงด้วยรหัสผิดตั้งใจ (req.SetBasicAuth("somchai", "wrong")):
[server] r.BasicAuth() -> user="somchai" pass="wrong" ok=true
รหัสผิดชัด ๆ แต่ ok ยังเป็น true
เพราะ ok ตอบคำถามว่า "แกะ header สำเร็จไหม" — คือมี header Authorization · ขึ้นต้นด้วย Basic · base64 ข้างหลังถอดได้ · มี : คั่น
Go ไม่มีทางรู้เลยว่ารหัสที่ถูกต้องของระบบคุณคืออะไร มันจึงเทียบให้ไม่ได้ตั้งแต่ต้น
ตัวเลือกที่ 3 ผิดเพราะ r.BasicAuth() ไม่คืน error และไม่ panic — คืนแค่ bool
ตัวเลือกที่ 4 ผิดเพราะมันไม่ได้เช็ค username ด้วยซ้ำ แค่แกะออกมาเฉย ๆ
วิธีที่ถูก: เทียบเองทั้ง user และ pass ด้วย subtle.ConstantTimeCompare แล้วตอบ 401 พร้อม WWW-Authenticate เมื่อไม่ตรง

3. เพื่อนส่ง JWT มาให้ดูแล้วบอกว่า "ไม่เป็นไรหรอก ข้างในใส่รหัสผ่านไว้ แต่มันเข้ารหัสอยู่" — ตอบเขายังไง?

ผมพิสูจน์สองทางในหัวข้อ 5 ด้วย token จากสไลด์:
ทางที่ 1 — shell ล้วน ๆ: echo '<payload>=' | tr '_-' '/+' | base64 -d ได้ {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
ทางที่ 2 — Go ด้วย ParseUnverified ที่ไม่รับ secret เข้าไปเลย: ได้ err=<nil> และเห็น admin:true ครบ
ตัวเลือกที่ 1 ผิดเพราะ JWT มาตรฐาน (JWS) ไม่เข้ารหัส payload เลย — แค่ base64
ตัวเลือกที่ 2 ผิดเพราะ alg ไม่เกี่ยวกับการอ่าน payload เลย มันเกี่ยวกับการตรวจ signature อย่างเดียว
ตัวเลือกที่ 3 คือกับดักที่หลอกที่สุด — มันเข้าใจหน้าที่ของ signature ถูก แต่ตอบผิดโจทย์: signature กันการแก้ไข ไม่ได้กันการอ่าน และปัญหาของเพื่อนคือรหัสผ่านถูกอ่านไปแล้ว ไม่ใช่กลัวคนแก้
จุดที่ต้องจำ: ถือว่า payload ของ JWT เป็นข้อความสาธารณะเสมอ — ห้ามใส่รหัสผ่าน เลขบัตร หรือความลับใด ๆ ลงไป

4. โค้ดเขียน jwt.Parse(s, keyfunc) เฉย ๆ ไม่ได้ใส่ jwt.WithValidMethods() — ข้อไหนอธิบายความเสี่ยงได้ตรงกับผลรันจริงที่สุด?

ผลรันของ cmd/algnone ปฏิเสธตัวเลือกที่ 1 ตรง ๆ:
B) ไม่มี WithValidMethods -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
ทางเดียวที่ alg=none ทะลุได้คือคนเขียนโค้ดคืน jwt.UnsafeAllowNoneSignatureType เอง (เคส C ได้ err=<nil>)
ส่วนของจริงที่น่ากลัวอยู่ในผลรันของ cmd/confusion:
A) ไม่มี WithValidMethods -> err = token signature is invalid: key is of invalid type: HMAC verify expects []byte
ข้อความนี้บอกเราว่ามันรอดเพราะ keyfunc คืน *rsa.PublicKey แล้วชนิดไม่ตรงกับที่ HMAC ต้องการ ไม่ใช่เพราะมันเช็คว่า alg ผิด — วันไหน keyfunc คืน []byte ช่องโหว่เปิดทันที
เทียบกับตอนใส่: B) มี WithValidMethods([RS256]) -> signing method HS256 is invalid ซึ่งปฏิเสธเพราะ alg ตรง ๆ
ตัวเลือกที่ 3 ผิดเพราะมองข้าม algorithm confusion · ตัวเลือกที่ 4 ผิดชัดเพราะ signature ยังถูกตรวจอยู่เสมอ
จุดที่ต้องจำ: ใส่ WithValidMethods ทุกครั้ง เพราะเราต้องการกันโดยตั้งใจ ไม่ใช่กันโดยบังเอิญ

🛠️ ภารกิจ — ถอด token ด้วยมือ แล้วลองโจมตีระบบของตัวเอง

เป้าหมายไม่ใช่ "ทำให้ JWT ใช้ได้" — เป้าหมายคือ ทำให้คุณเห็นด้วยตาตัวเองว่าอะไรที่ token กันได้ และอะไรที่มันกันไม่ได้

  1. ถอดด้วยมือก่อน: ก๊อป token ตัวในหัวข้อ 5 มา ตัดด้วย cut -d. -f1 แล้ว -f2 แล้ว decode ทั้งสองส่วน
    ห้ามใช้เว็บ jwt.io — พิมพ์คำสั่งเองจะได้รู้ว่ามันไม่มีอะไรมากไปกว่า base64
    อย่าลืมเติม = ให้ครบ 4 ตัว ไม่งั้นเลขท้ายจะหายไปเงียบ ๆ
  2. ตั้งโปรเจกต์: go mod init jwtcheck · go get github.com/golang-jwt/jwt/v5
    เขียนโปรแกรมที่ sign token ด้วย HS256 โดยใส่ sub · role · iat · exp (ตั้ง 1 ชั่วโมง) แล้วพิมพ์ token กับ len() ออกมา
  3. parse กลับ: เขียนฝั่ง verify ด้วย jwt.Parse + jwt.WithValidMethods([]string{"HS256"})
    พิมพ์ claims ออกมา แล้วลองเขียน c["exp"].(int) ดูสักครั้ง เพื่อให้เห็น panic ด้วยตาตัวเอง ก่อนจะแก้เป็น .(float64)
  4. สร้างสามเคส error: ตั้ง exp เป็นอดีต · แก้ตัวอักษรสุดท้ายของ token ไปหนึ่งตัว · parse ด้วย WithValidMethods([]string{"RS256"})
    จดข้อความ error ทั้งสามอันไว้ แล้วเทียบกับที่อยู่ในหัวข้อ 7 ว่าตรงกันไหม
  5. ทดสอบ errors.Is: ในทั้งสามเคสข้างบน พิมพ์ผลของ errors.Is(err, jwt.ErrSignatureInvalid) กับ errors.Is(err, jwt.ErrTokenSignatureInvalid)
    ตอบให้ได้ว่ามีเคสไหนที่สองอันนี้ให้คำตอบไม่เหมือนกัน และควรใช้ตัวไหน
  6. ลองโจมตีตัวเอง: สร้าง token alg=none ด้วยมือ — base64url ของ {"alg":"none","typ":"JWT"} + . + base64url ของ {"role":"admin"} + . (ปิดท้ายด้วยจุดเปล่า)
    ยิงเข้า parser ของคุณสามแบบ: มี WithValidMethods · ไม่มี · และให้ keyfunc คืน jwt.UnsafeAllowNoneSignatureType
    แบบไหนที่ทะลุได้จริง?
  7. กับดักตลอดชีพ: sign token ที่ไม่มี exp เลย แล้ว parse ดู — จะได้ err=<nil>
    จากนั้นเติม jwt.WithExpirationRequired() แล้วรันซ้ำ
    ตอบว่าทำไม default ถึงเป็นแบบนี้ และคุณจะกันไม่ให้เกิดในทีมยังไง
  8. ท้าทาย: เปลี่ยนจาก jwt.MapClaims ไปเป็น struct ของตัวเองที่ฝัง jwt.RegisteredClaims ข้างใน แล้วใส่ field Role string เพิ่ม
    แล้วตอบว่า ปัญหา float64 ในข้อ 3 หายไปไหม และทำไม

ผลรันจริงของทั้งสามแบบ:

evil token = eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.

A) มี WithValidMethods([HS256]) -> err = token signature is invalid: signing method none is invalid
B) ไม่มี WithValidMethods    -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
C) คืน UnsafeAllowNoneSignatureType -> err = <nil>

มีแบบ C แบบเดียวที่ทะลุ — และมันทะลุเพราะคนเขียนโค้ดอนุญาตเอง ไม่ใช่เพราะไลบรารีหละหลวม
ถ้าคุณเคยได้ยินว่า "ไม่ใส่ WithValidMethods แล้ว alg=none ทะลุ" — กับ golang-jwt/jwt/v5 v5.3.1 ไม่จริง ดูเคส B

แต่อย่าเพิ่งสรุปว่าไม่ต้องใส่ ลองข้อ 6 ต่ออีกนิด: เขียนโปรแกรมที่ keyfunc คืน *rsa.PublicKey แล้วให้ "ผู้โจมตี" เอา public key นั้นไปเซ็นแบบ HS256
คุณจะได้ error ว่า key is of invalid type: HMAC verify expects []byte — ซึ่งแปลว่ามันรอดเพราะชนิดของ key บังเอิญไม่ตรง ไม่ใช่เพราะตรวจ alg
นั่นคือเหตุผลจริงที่ต้องใส่ WithValidMethods ทุกครั้ง

💬 ข้อ 1 กับข้อ 6 คือสองข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ — ข้อ 1 ทำให้คุณเลิกกลัว JWT (มันคือ base64 กับ JSON เท่านั้นเอง) ส่วนข้อ 6 ทำให้คุณเลิกเชื่อว่ามันปลอดภัยเองโดยอัตโนมัติ
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 กับ golang-jwt/jwt/v5 v5.3.1
จุดที่มีโอกาสไม่ตรงมากที่สุดคือ ตัว token ที่คุณ sign เอง — มันจะไม่เหมือนของผมแน่นอน เพราะ iat กับ exp เป็นเวลาตอนที่รัน ดูที่ข้อความ error กับโครงสร้างแทน อย่าดูที่ตัวอักษรของ token

อ้างอิง