Cheat sheet · Day 9

Config · .env · Validate · JWT · OAuth · Keycloak — แผ่นเดียวจบ

คู่กับ บทเรียน 45–53 · ทุกผลลัพธ์ในหน้านี้มาจากการรันจริงบน Go 1.26.5 darwin/arm64

หน้านี้ไม่ได้เขียนไว้ให้อ่านรวดเดียว — เขียนไว้ให้เปิดตอนมือติดคีย์บอร์ด
ถ้ากำลังรีบ: หัวข้อ 3 (กับดักของ caarlos0/env) · หัวข้อ 14 (ตารางกับดักรวมทั้งวัน) และหัวข้อ 15 (ตารางแปล error) คือสามอันที่จะได้ใช้บ่อยที่สุด
หัวข้อ 7 กับ 9 เป็นโค้ดที่ตรวจแล้วว่า เติม import ให้ครบแล้วคอมไพล์ผ่าน — สไลด์ตัดบล็อก import ออกให้อ่านง่าย ตอนก็อปไปใช้ต้องเติมเอง

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

go version go1.26.5 darwin/arm64

github.com/caarlos0/env/v11             v11.4.1
github.com/joho/godotenv                v1.5.1
github.com/go-playground/validator/v10  v10.30.3
github.com/golang-jwt/jwt/v5            v5.3.1
golang.org/x/oauth2                     v0.36.0
github.com/coreos/go-oidc/v3            v3.20.0
gorm.io/gorm                            v1.31.2
gorm.io/driver/postgres                 v1.6.2
goose                                   v3.27.3
quay.io/keycloak/keycloak:26.7          # tag นี้ชี้ไปที่ 26.7.2
postgres:16-alpine                      # PostgreSQL 16.15 บน colima

ที่ต้องรู้ก่อนอ่านต่อ: ผมรัน Postgres ที่พอร์ต 55432 และ Keycloak ที่ 8081 เพราะพอร์ตมาตรฐานบนเครื่องผมมีของอื่นจองอยู่ — เห็นเลขพอร์ตแปลก ๆ ใน DSN ตัวอย่างก็คือสาเหตุนี้ ไม่ใช่กฎอะไร

สารบัญ

  1. เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน
  2. caarlos0/env — struct tag · option · type ที่รองรับ
  3. caarlos0/env — กับดัก
  4. godotenv — autoload vs Load vs Overload
  5. .env — รูปแบบไฟล์ + .gitignore
  6. validator/v10 — tag · error จริง · errors.Join · Humanize
  7. โครง internal/config ที่ก็อปไปใช้ได้
  8. ท่อลำเลียงค่าทั้งเส้น
  9. JWT — โครงสร้าง · golang-jwt/v5 · error ทุกเคส
  10. OAuth 2.0 — ตัวละคร 4 ตัว + Authorization Code Grant
  11. Keycloak — compose · endpoint · components
  12. flow /login /callback /exchange /logout
  13. goose + ตาราง users
  14. ตารางกับดักรวมทั้งวัน
  15. ตารางแปล error / panic ที่เจอบ่อย

1 · เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน

ของเวอร์ชันติดตั้งยังไง
Go1.26.5 darwin/arm64—
github.com/caarlos0/env/v11v11.4.1go get github.com/caarlos0/env/v11
github.com/joho/godotenvv1.5.1go get github.com/joho/godotenv
github.com/go-playground/validator/v10v10.30.3go get github.com/go-playground/validator/v10
github.com/golang-jwt/jwt/v5v5.3.1go get github.com/golang-jwt/jwt/v5
golang.org/x/oauth2v0.36.0go get golang.org/x/oauth2
github.com/coreos/go-oidc/v3v3.20.0go get github.com/coreos/go-oidc/v3
gorm.io/gorm · gorm.io/driver/postgresv1.31.2 · v1.6.2ของเดิมจาก Day 7
goose (CLI)v3.27.3ของเดิมจาก Day 7
Keycloakimage tag 26.7 → เวอร์ชันจริง 26.7.2quay.io/keycloak/keycloak:26.7
PostgreSQL16.15 (aarch64-unknown-linux-musl)postgres:16-alpine
Dockerclient 29.6.2 · server 29.5.2ผ่าน colima ไม่ใช่ Docker Desktop
zero dependency จริงยิ่งกว่าที่โฆษณา
$ cat $(go env GOMODCACHE)/github.com/caarlos0/env/v11@v11.4.1/go.mod
module github.com/caarlos0/env/v11
// (มีแต่ retract 3 บรรทัด กับ go 1.18 — ไม่มี require เลยสักบรรทัด)
go 1.18

$ ls -l .../env/v11@v11.4.1/go.sum
--- go.sum: 0B (ไฟล์ว่างเปล่า) ---

แม้แต่ env_test.go ก็ใช้แค่ stdlib — encoding/base64, encoding/json, errors, fmt, net/http, net/url, os, path/filepath, reflect, runtime, strconv, strings, testing, time — ไม่มี testify · เกร็ด: go.mod ประกาศ retract ไว้สามเวอร์ชัน (v11.0.1 / v11.2.0 / v11.3.0)

⚠ เครื่องที่ล้าง Docker Desktop ทิ้งแล้วใช้ colima

อาการที่เจอจริงบนเครื่องทดสอบ สองอย่าง:
① docker compose (แบบ subcommand) ใช้ไม่ได้ ขึ้น docker: unknown command: docker compose เพราะ ~/.docker/cli-plugins/docker-compose เป็น symlink ค้างไปที่ /Applications/Docker.app ที่ถูกลบไปแล้ว → ใช้ docker-compose (มีขีด) แทน
② docker pull พังด้วย error getting credentials - err: exec: "docker-credential-osxkeychain": executable file not found in $PATH ซึ่งเป็นซาก credsStore ใน ~/.docker/config.json

2 · caarlos0/env — struct tag · option · type ที่รองรับ

โครงพื้นฐาน

import "github.com/caarlos0/env/v11"

type Config struct {
	AppPort         int           `env:"APP_PORT" envDefault:"8080"`
	ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
	LogLevel        string        `env:"LOG_LEVEL" envDefault:"DEBUG"`
	PostgresDSN     string        `env:"POSTGRES_DSN"`
}

cfg, err := env.ParseAs[Config]()
--- run 1: no env set (defaults) ---
cfg=main.Config{AppPort:8080, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
err=<nil>
AppPort type=int value=8080
ShutdownTimeout type=time.Duration value=10s ms=10000
--- run 2: env set ---
cfg=main.Config{AppPort:9999, ShutdownTimeout:90000000000, LogLevel:"INFO", PostgresDSN:"postgres://u:p@localhost:5432/db"}
err=<nil>
AppPort type=int value=9999
ShutdownTimeout type=time.Duration value=1m30s ms=90000

PostgresDSN ไม่มี envDefault และไม่ได้ตั้ง env → ได้ "" เงียบ ๆ ไม่ error เพราะไม่ได้ใส่ ,required

struct tag ทั้ง 4 ตัว

tagทำอะไรเขียนยังไงไม่ใส่แล้วเป็นยังไง
envบอกว่า field นี้อ่านจาก env ชื่ออะไร`env:"APP_PORT"`field นั้นไม่ถูกอ่านเลย
envDefaultค่าที่ใช้เมื่อไม่มี env ตัวนั้น`env:"APP_PORT" envDefault:"8080"`ได้ zero value ของ type นั้น
envSeparatorตัวคั่นของ slice`env:"ORIGINS" envSeparator:";"`ใช้ , เป็นค่าเริ่มต้น
envPrefixเติมหน้า key ของ struct ซ้อนPrimary DB `envPrefix:"DB_"`struct ซ้อนอ่าน key เปล่า ๆ (เช่น HOST)
$ HOSTS="a.com,b.com,c.com" ORIGINS="x.io;y.io" PORTS="1|2|3" go run .
[Sep] main.Sep{DefaultSep:[]string{"a.com", "b.com", "c.com"}, CustomSep:[]string{"x.io", "y.io"},
      IntsSep:[]int{1, 2, 3}, SepDefault:[]string{"a", "b", "c"}} err=<nil>

envSeparator ใช้ได้กับ slice ของ type อื่นด้วย ไม่ใช่แค่ []string (ทดสอบ []int ผ่าน) · และ envDefault ก็ถูก split ด้วย separator เดียวกัน (SepDefault มาจาก envDefault:"a,b,c" ทั้งที่ไม่ได้ตั้ง env TAGS เลย)

$ DB_HOST="pg.local" DB_PORT=6543 REDIS_HOST="redis.local" HOST="bare.local" go run .
[Nested] main.Nested{Primary:main.DB{Host:"pg.local", Port:6543},
         Cache:main.Redis{Host:"redis.local"}, NoPfx:main.DB{Host:"bare.local", Port:5432}} err=<nil>

NoPfx เป็น struct ซ้อนที่ไม่ได้ใส่ envPrefix — มันอ่าน HOST เปล่า ๆ · มีหลาย struct ซ้อนแล้วลืมใส่ prefix เมื่อไหร่ ทั้งคู่จะไปแย่ง key เดียวกัน

option ท้าย tag — เขียนต่อหลังชื่อ key คั่นด้วย ,

optionเช็คอะไรerror จริงเมื่อไม่ผ่าน
,required มี env ตัวนี้อยู่ไหม — ไม่ได้เช็คว่าว่างหรือเปล่า env: required environment variable "MYVAR2" is not set
,notEmpty ต้องมี และห้ามเป็นค่าว่าง env: environment variable "NE_VAR" should not be empty
,file เอาค่า env เป็น path แล้วอ่านเนื้อไฟล์มาใส่แทน env: could not load content of file "/definitely/not/here.txt" from variable F_VAR: open /definitely/not/here.txt: no such file or directory
,unset parse เสร็จแล้ว ลบ env ตัวนั้นทิ้ง ไม่มี error — เป็นการกระทำ ไม่ใช่การตรวจ
// ,unset — ค่าเข้า struct ครบ แต่หลังจากนั้น env หายไปจริง
[unset] parsed="temp-value" err=<nil> ; after parse LookupEnv -> value="" exists=false

,unset เหมาะกับ secret ที่ไม่อยากให้ process ลูกเห็นต่อ — โปรแกรมเราอ่านไปแล้ว ใครที่ fork ต่อจากเราจะไม่เห็นอีก

⚠ ,file ไม่ trim ขึ้นบรรทัดใหม่
$ # ไฟล์มีเนื้อว่า "s3cr3t-from-file\n"
[file ok] value="s3cr3t-from-file\n" err=<nil>

ได้ \n ติดมาด้วย · ถ้าเอาไปใช้กับ Docker secret (/run/secrets/db_password) รหัสผ่านจะผิดแบบหาสาเหตุไม่เจอ เพราะ log จะพิมพ์ออกมาดูเหมือนถูกทุกตัวอักษร — ต้อง strings.TrimSpace เองเสมอ

type ที่รองรับ — รันครบทุกตัวแล้ว

$ I8=-12 I64=9223372036854775807 U16=65535 U64=18446744073709551615 F32=3.14 F64=2.718281828 \
  B=true S=hello D=1h30m LOC=Asia/Bangkok U="https://api.example.com:8443/v1/users?q=1" \
  UP="postgres://user@db:5432/app" PI=42 PS=ptrval SL="a,b,c" SLD="1s,2m,3h" \
  M="a:1,b:2" MS="k1:v1,k2:v2" LVL=info LVLP=warn T="2026-08-22T10:30:00Z" go run .

I8=-12 I64=9223372036854775807 U16=65535 U64=18446744073709551615
F32=3.14 F64=2.718281828 B=true S="hello"
D=1h30m0s (time.Duration)
LOC=Asia/Bangkok (*time.Location)
U=url.URL{Scheme:"https", Opaque:"", User:(*url.Userinfo)(nil), Host:"api.example.com:8443",
  Path:"/v1/users", Fragment:"", RawQuery:"q=1", RawPath:"", RawFragment:"",
  ForceQuery:false, OmitHost:false}
UP=postgres://user@db:5432/app (*url.URL)
PI=0x5de58d7140d0 deref=42
PS deref="ptrval"
SL=[]string{"a", "b", "c"}
SLD=[]time.Duration{1000000000, 120000000000, 10800000000000}
M=map[string]int{"a":1, "b":2}
MS=map[string]string{"k1":"v1", "k2":"v2"}
Lvl=main.LogLevel{v:"INFO"} LvlP=&{WARN}
T=2026-08-22 10:30:00 +0000 UTC (time.Time)
กลุ่มtypeรูปแบบค่าใน env
จำนวนเต็มint int8 int16 int32 int64 · uint …uint64-12 · 65535
ทศนิยม / bool / stringfloat32 float64 bool string3.14 · true · hello
เวลาtime.Duration1h30m — ต้องมีหน่วยเสมอ
เวลาtime.Time2026-08-22T10:30:00Z (RFC3339)
โซนเวลา*time.LocationAsia/Bangkok
URLurl.URL · *url.URLhttps://api.example.com:8443/v1/users?q=1
slice[]string · []int · []time.Durationa,b,c (เปลี่ยนตัวคั่นด้วย envSeparator)
mapmap[string]int · map[string]stringa:1,b:2 — : คั่น key/value · , คั่นคู่
pointer*int · *string · …เหมือน type ที่มันชี้
ของเราเองอะไรก็ได้ที่เขียน encoding.TextUnmarshalerตามที่เราเขียนเอง

time.Time ใช้ได้ทั้งที่ README ไม่ได้ list ไว้ — เพราะมันเป็น encoding.TextUnmarshaler อยู่แล้ว · นี่คือเหตุผลว่าทำไม TextUnmarshaler สำคัญ: เขียนเมธอดเดียวแล้ว type ของเราใช้กับ env ได้ทันที

// custom type ที่คืน error → env ห่อให้เป็น parse error
$ LVL=verbose go run .
ERR: env: parse error on field "Lvl" of type "main.LogLevel": unknown log level "VERBOSE"
⚠ ไม่ตั้ง env เลย = pointer / slice / map เป็น nil
D=0s (time.Duration)
LOC=UTC (*time.Location)          ← ไม่ใช่ nil!
UP=<nil> (*url.URL)
PI=nil
PS=nil
SL=[]string(nil)
M=map[string]int(nil)             ← nil map: เขียนลงไปจะ panic
T=0001-01-01 00:00:00 +0000 UTC (time.Time)

*time.Location เป็นข้อยกเว้นตัวเดียว — ไม่ตั้ง env แล้วได้ UTC ไม่ใช่ nil ต่างจาก pointer ตัวอื่นทั้งหมด · อย่าเขียนโค้ดที่เช็ค == nil เหมือนกันหมด

3 · caarlos0/env — กับดัก

① ,required คู่กับ envDefault = required ไม่มีผลเลย

type A struct { Both        string `env:"MYVAR,required" envDefault:"fallback"` }
type B struct { OnlyRequired string `env:"MYVAR2,required"` }
=== ไม่ตั้ง env เลย ===
[A required+envDefault] value="fallback" err=<nil>
[B required only]       value=""         err=env: required environment variable "MYVAR2" is not set

A มี required แต่ได้ default ไปเงียบ ๆ err=<nil> · B ที่มี required เปล่า ๆ error ตามคาด · เหตุผลอยู่ในซอร์ส env.go ฟังก์ชัน getOr (env.go:648-660 ของ v11.4.1):

func getOr(key, defaultValue string, defExists bool, envs map[string]string) (val string, exists, isDefault bool) {
	value, exists := envs[key]
	switch {
	case (!exists || key == "") && defExists:
		return defaultValue, true, true   // ← บรรทัด 651-652 · คืน exists=true
	case exists && value == "" && defExists:
		return defaultValue, true, true   // ← ตั้งเป็นค่าว่างก็ยังได้ default

พอ exists เป็น true เสมอเมื่อมี envDefault เงื่อนไขที่ env.go:613

if fieldParams.Required && !exists && fieldParams.OwnKey != "" {

ก็ไม่มีวันเป็นจริง

② ,notEmpty คู่กับ envDefault ก็โดนกลบเหมือนกันเป๊ะ

type C struct { BothNotEmpty string `env:"MYVAR3,notEmpty" envDefault:"fallback3"` }
=== ไม่ตั้ง env เลย ===
[C notEmpty+envDefault] value="fallback3" err=<nil>
=== ตั้ง MYVAR3 ว่าง (MYVAR3=) ===
[C notEmpty+envDefault] value="fallback3" err=<nil>

ตั้งเป็นค่าว่างก็ยังได้ default (มี case exists && value == "" && defExists ใน getOr อีกอัน) · กฎที่จำง่ายที่สุด: ใส่ envDefault เมื่อไหร่ required และ notEmpty ตายทันที

⚠ กับดักที่อันตรายกว่าข้อ ① อีก — ,required ไม่กันค่าว่าง
$ MYVAR2= go run .            # ตั้งเป็นสตริงว่าง
[B required only]       value="" err=<nil>

,required เช็คแค่ “มี env ตัวนี้อยู่ไหม” (ซอร์ส env.go:613 ดูแค่ exists ไม่ได้ดูค่า) · ใน docker-compose.yml การเขียน POSTGRES_DSN= ทิ้งไว้แบบไม่ใส่ค่าเกิดขึ้นง่ายมาก และมันจะผ่านฉลุย
อยากกันค่าว่างจริง ต้องใช้ ,notEmpty และต้องไม่มี envDefault

③ แปลง type ไม่ได้ — struct ไม่ได้ว่างทั้งก้อน

$ APP_PORT=abc go run t1.go
cfg=main.Config{AppPort:0, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
err=env: parse error on field "AppPort" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
AppPort type=int value=0
ShutdownTimeout type=time.Duration value=10s ms=10000

สังเกตให้ดี: field ที่พังได้ zero value ส่วน field อื่นถูกเติมครบตามปกติ (ShutdownTimeout=10s, LogLevel=DEBUG) · ใครลืมเช็ค err จะได้ AppPort=0 ไปใช้ต่อ แล้ว server จะ bind พอร์ตสุ่ม

④ time.Duration ที่ลืมใส่หน่วย

$ APP_SHUTDOWN_TIMEOUT=10 go run t1.go
cfg=main.Config{AppPort:8080, ShutdownTimeout:0, LogLevel:"DEBUG", PostgresDSN:""}
err=env: parse error on field "ShutdownTimeout" of type "time.Duration": unable to parse duration: time: missing unit in duration "10"

เขียน 10 คิดว่าเป็นวินาที — ต้องเขียน 10s · มือใหม่พลาดตรงนี้บ่อยที่สุดในบทนี้

⑤ error หลายจุดพร้อมกัน — env.AggregateError ไม่ fail fast

$ M_C=notanint go run .     # struct มี M_A,required · M_B,required · M_C int
[multi errors] err=env: required environment variable "M_A" is not set; required environment variable "M_B" is not set; parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax
    type=env.AggregateError
    inner[0] env.VarIsNotSetError: required environment variable "M_A" is not set
    inner[1] env.VarIsNotSetError: required environment variable "M_B" is not set
    inner[2] env.ParseError: parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax

ข่าวดี: มันบอกทุกปัญหาพร้อมกันคั่นด้วย "; " ไม่ใช่หยุดที่ตัวแรก — แปลว่าแก้ env ทีเดียวจบ ไม่ต้องรัน–แก้–รันทีละตัว
เจาะทีละตัวได้ด้วย errors.As กับ env.VarIsNotSetError · env.EmptyVarError · env.ParseError · env.LoadFileContentError หรือ type assert เป็น env.AggregateError แล้ววน .Errors

4 · godotenv — autoload vs Load vs Overload

วิธีที่ 1 — autoload (สั้นที่สุด)

import _ "github.com/joho/godotenv/autoload"
// จบ — ไม่ต้องเรียกอะไรอีกเลย os.Getenv ได้ค่าจาก .env ทันที
--- run 1: autoload, no shell env ---
APP_NAME="from-dotenv"
PORT="9999"

ทั้ง package autoload มีแค่ 15 บรรทัด และส่วนที่ทำงานจริงคือ:

import "github.com/joho/godotenv"

func init() {
	godotenv.Load()
}

ไม่รับค่า error กลับมาเลย — ทิ้งทั้งดุ้น · จำบรรทัดนี้ไว้ เดี๋ยวจะได้เห็นราคาของมัน

// พิสูจน์ว่าค่ามาถึงก่อน main จริง (ไม่ใช่แค่อ่านซอร์สเดา)
1) package-level var init: GOOD=set-by-dotenv
2) main package init(): GOOD = set-by-dotenv
3) main(): GOOD = set-by-dotenv

ค่ามาแล้วตั้งแต่ตอน package-level var init ซึ่งเกิดก่อนทุกอย่างของ main

วิธีที่ 2 — เรียก godotenv.Load() เอง

func main() {
	if err := godotenv.Load(); err != nil {
		slog.Debug("no .env file", "err", err)
	}
	cfg, err := env.ParseAs[Config]()
	// ...
}
=== with .env ===
after Load, raw os.Getenv PORT="8081"
ParseAs err=<nil> cfg={AppName:svc-from-dotenv Port:8081}

Load vs Overload

godotenv.Load()godotenv.Overload()
env ที่มีอยู่แล้วชนะ — ไม่ถูกทับแพ้ — ถูก .env ทับ
env ที่ยังไม่มีเอาค่าจาก .env มาใส่เอาค่าจาก .env มาใส่
ตัดสินเป็นรายkey ไม่ใช่ทั้งไฟล์ — key ที่ shell ไม่มี ก็ยังได้จาก .env
autoload ใช้ตัวไหนใช้ตัวนี้—
เอาไว้ทำอะไรค่าจริงจาก shell/CI/k8s ต้องชนะเสมออยากบังคับให้ .env ชนะจริง ๆ (ระวังมาก)
$ APP_NAME=from-shell go run . load
before: APP_NAME="from-shell"
err=<nil>
after(load): APP_NAME="from-shell"          ← shell ชนะ

$ APP_NAME=from-shell go run . overload
before: APP_NAME="from-shell"
err=<nil>
after(overload): APP_NAME="from-dotenv"     ← .env ชนะ

ซอร์สเขียนไว้ตรง ๆ — godotenv.go:50 ว่า “It's important to note that it WILL NOT OVERRIDE an env variable that already exists” ส่วน godotenv.go:73 ว่า “this WILL OVERRIDE an env variable that already exists”

ไม่มีไฟล์ .env เลย

$ # วิธีที่ 2 — จับ error ได้
err (%v)   = open .env: no such file or directory
err type   = *fs.PathError
os.ErrNotExist? = true
program still running, did not panic
exit code: 0

อยากแยก “ไม่มีไฟล์” (ปกติบน prod) ออกจาก “ไฟล์พัง” (ต้องดัง) ใช้ errors.Is(err, os.ErrNotExist) ได้เลย ดีกว่า slog.Debug เฉย ๆ

ใครทำอะไร — godotenv vs caarlos0/env

เรื่องgodotenvcaarlos0/env
หน้าที่ในหนึ่งประโยคยกค่าจากไฟล์ .env เข้าไปเป็น environment variableอ่านenvironment variable ใส่ struct
ทำงานตอนไหนautoload = init() ก่อน main · Load() = ตอนที่เราเรียกเองตอนเรียก env.ParseAs() เท่านั้น
แปลง type ให้ไหมไม่ — ทุกอย่างเป็น stringให้ — int / Duration / slice / map / URL / custom
ค่า defaultไม่มีแนวคิดนี้envDefault
บังคับว่าต้องมีค่าไม่มี,required · ,notEmpty
บน productionไม่ได้ใช้ — prod inject env ตรง ๆใช้ทั้ง dev และ prod
ถ้าเอาออกจากโปรเจกต์dev ต้อง export เองทุกตัว แต่แอปยังทำงานต้องเขียน os.Getenv + strconv เองทุก field
⚠ ประโยค “godotenv ทำงานก่อนสุด — init()” จริงเฉพาะ autoload

วิธีที่ 2 วาง godotenv.Load() ไว้ใน main() ซึ่งไม่ใช่ init() · สองวิธีนี้ต่างกันจริงที่จุดนี้ อย่าจำรวมกัน
ส่วนครึ่งหลังจริงเสมอ: env.ParseAs() อ่าน os env ณ วินาทีที่ถูกเรียก — อะไรที่ยัดเข้า env หลังจากนั้นมันไม่รู้เรื่อง

5 · .env — รูปแบบไฟล์ที่รองรับ + สิ่งที่ต้องใส่ .gitignore

เขียนใน .envค่าที่ได้จริงหมายเหตุ
# this is a comment—ทั้งบรรทัดถูกข้าม ไม่กลายเป็น key
PLAIN=hello"hello"รูปพื้นฐาน
SPACED=hello world"hello world"ช่องว่างในค่าไม่ต้อง quote ก็ได้
SPACES_AROUND = padded"padded"trim ช่องว่างรอบ = ให้
DQ="double quoted value""double quoted value"quote คู่
SQ='single quoted value'"single quoted value"quote เดี่ยว
export EXPORTED2=yes-with-export"yes-with-export"มี export นำหน้าก็อ่านได้
MULTILINE="line1⏎line2⏎line3"ขึ้นบรรทัดใหม่จริงhex ที่ได้: … 31 0a 6c … — 0a คือ newline จริง
TRAILING=value # trailing comment?"value"comment ท้ายบรรทัดถูกตัดออก
DOLLAR="literal $PLAIN""literal hello"quote คู่ ขยาย $VAR ให้
SQ_DOLLAR='literal $PLAIN'"literal $PLAIN"quote เดี่ยว ไม่ขยาย
ESCAPED="tab\there""tabthere"\t ไม่กลายเป็น tab — ดูกล่องข้างล่าง
⚠ สาม escape ที่ทำให้ secret เพี้ยนแบบเงียบ ๆ

① \t ถูกกินหาย — hex ที่ได้จริงคือ 74 61 62 74 68 65 72 65 = t a b t h e r e · ตัวที่สี่คือ 0x74 (ตัวอักษร t) ไม่ใช่ 0x09 (tab) · parser รู้จักแค่ \n กับ \r ที่เหลือ backslash ถูกตัดทิ้งเฉย ๆ (parser.go:192-205)

② รหัสผ่านที่มี $ ใน quote คู่จะถูกแทนค่า — แต่เฉพาะชื่อตัวพิมพ์ใหญ่ · regex ของ parser คือ (\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}? (parser.go:253) จึงจับได้แค่ [A-Z0-9_]+ · secret ที่มี $ ต้องใส่ quote เดี่ยว

# d2.env
BCD=SURPRISE
P1="a$BCD"        → "aSURPRISE"   ถูกแทนค่า
P3="p@ss$W0RD"    → "p@ss"       $W0RD ไม่มีค่า จึงถูกแทนด้วยค่าว่าง
P4="a${BCD}x"     → "aSURPRISEx"

# dollar.env — ชื่อพิมพ์เล็ก ไม่โดนอะไรเลย
bcd=SURPRISE
PASS="a$bcd"      → "a$bcd"      รอด เพราะ bcd เป็นตัวพิมพ์เล็ก

③ # ที่มีช่องว่างนำหน้าเท่านั้นที่เป็น comment — TOKEN=ab #cd ได้ "ab" แต่ TOKEN=ab#cd (ติดกัน) ได้ "ab#cd" เต็ม ๆ · token แบบ ghp_abc#123def จึงรอด · ใส่ quote ครอบไว้ก็หมดเรื่อง

ไฟล์ .env พัง — เกิดอะไรขึ้น

# bad1.env
GOOD=1
this line has no equals sign
AFTER=2
  err  = unexpected character "\n" in variable name near "this line has no equals sign\nAFTER=2\n"
  Load err = unexpected character "\n" in variable name near "this line has no equals sign\nAFTER=2\n"
  GOOD after Load = ""
  AFTER after Load = ""
# bad3.env — quote ไม่ปิด
GOOD=1
UNCLOSED="oops
AFTER=2
  err  = unterminated quoted value "oops
  Load err = unterminated quoted value "oops
  GOOD after Load = ""
  AFTER after Load = ""
⚠ all-or-nothing

GOOD=1 อยู่บรรทัดก่อนบรรทัดที่พัง แต่ก็ไม่ถูก set เลย — ทั้งไฟล์ถูกทิ้งหมด · คนส่วนใหญ่เดาว่ามันจะอ่านได้ถึงบรรทัดที่พังแล้วค่อยหยุด ซึ่งไม่ใช่

⚠ แต่บาง syntax ที่ดูพัง กลับไม่พัง
=== bad2.env ===        # มีบรรทัด 123INVALID=x
  err  = <nil>
  123INVALID => "x"
=== bad4.env ===        # มีบรรทัด =novalue (ไม่มีชื่อ key)
  err  = <nil>
   => "novalue"
  GOOD after Load = "1"

godotenv ไม่ validate ชื่อ key เลย — ชื่อขึ้นต้นด้วยตัวเลข ผ่าน · key ว่างเปล่า ผ่าน · err=<nil> ทั้งคู่ · พังเงียบกว่า syntax error จริง ๆ อีก เพราะไม่มีสัญญาณอะไรเลย

ราคาของ autoload ที่ต้องรู้

$ # .env พัง (บรรทัดไม่มี =) + import _ autoload อย่างเดียว
main package init() runs; GOOD =
main(): GOOD="" AFTER=""
no error was ever surfaced
exit code: 0
⚠ พิมพ์ .env ผิดตัวเดียว = แอปรันต่อโดยไม่มี env จาก .env เลย

ไม่ panic · ไม่มี log · exit 0 · เพราะ init() ของ autoload ทิ้ง error ทั้งดุ้น (ดูหัวข้อ 4)
นี่คือข้อแลกเปลี่ยนจริงของ “วิธีที่ 1 (แนะนำ)” — สั้นกว่า แต่ไม่มีทางรู้เลยว่าไฟล์พัง · วิธีที่ 2 อย่างน้อยยังจับ err ได้

.env ถูกหาจาก current working directory ไม่ใช่ที่ตั้งของ binary

=== run binary from project root (where .env lives) ===
cwd=/var/folders/.../p8       WHERE="root-dotenv"
=== run same binary from sub/ (no .env there) ===
cwd=/var/folders/.../p8/sub   WHERE=""

binary ตัวเดียวกันเป๊ะ ผลต่างกันเพราะ cwd ต่างกัน · ยืนยันจากซอร์ส godotenv.go:179 ที่ return []string{".env"} ซึ่งเป็น path สัมพัทธ์ · จุดนี้กัดคนตอนขึ้น Docker / systemd บ่อยมาก

.env เป็นแค่ไฟล์ text ธรรมดา — Go, ระบบปฏิบัติการ และ Docker ไม่รู้จักมันโดยกำเนิด · ที่มันทำงานได้เพราะมีไลบรารีตัวหนึ่ง open(".env") ให้เท่านั้น · หลักฐานคือผลข้างบน: ถ้าระบบรู้จักมันจริง binary ตัวเดิมย่อมหาไฟล์เจอไม่ว่ารันจากไหน

สิ่งที่ต้องใส่ .gitignore

# .gitignore
.env
.env.*
!.env.example
ไฟล์commit ได้ไหมข้างในมีอะไร
.envห้ามค่าจริงของเครื่องเรา — DSN จริง รหัสจริง client_secret จริง
.env.local · .env.productionห้ามเหมือนกัน แค่คนละสภาพแวดล้อม
.env.exampleต้อง commitรายชื่อ key ครบทุกตัว แต่ค่าเป็นของปลอม/ว่าง
# .env.example — ก็อปเป็น .env แล้วเติมค่าจริงเอง
APP_NAME=wongnok
APP_ENV=development
APP_PORT=8080
APP_SHUTDOWN_TIMEOUT=10s
LOG_LEVEL=info
LOG_FORMAT=json
POSTGRES_DSN=postgres://user:pass@localhost:5432/wongnok?sslmode=disable

ทำไมไม่ commit .env ถึงปลอดภัยพอ — สองผลรันข้างบนหนุนกันพอดี: ① .env ไม่ถูกอ่านถ้า cwd ไม่ตรง และ ② Load() ให้ env จริงชนะ .env เสมอ · แปลว่า production ที่ inject env เข้ามาตรง ๆ จะไม่มีทางโดน .env แย่งอยู่แล้ว ไฟล์นี้จึงเป็นของ local dev ล้วน ๆ

6 · validator/v10 — tag · error จริง · errors.Join · Humanize

import "github.com/go-playground/validator/v10"

// ตัวเดียวทั้ง package — validator แคชข้อมูล struct ไว้ข้างใน
var validate = validator.New(validator.WithRequiredStructEnabled())

err := validate.Struct(cfg)

tag ที่ใช้บ่อย

tagแปลว่าตัวอย่างที่ต้องระวัง
requiredห้ามเป็น zero value ของ type นั้นvalidate:"required"0 · "" · 0s · false ตกหมด
min / maxความยาว string / จำนวนสมาชิก slicevalidate:"required,min=3"comma = AND ต้องผ่านทุกข้อ
gte / lteช่วงตัวเลข (inclusive ทั้งคู่)validate:"gte=1,lte=65535"ใช้กับ time.Duration ได้ แต่ดูกล่องเตือน
gt / ltช่วงตัวเลขแบบไม่รวมขอบvalidate:"required,gt=0"ตัวเดียวที่กันค่าติดลบได้
oneofต้องเป็นค่าใดค่าหนึ่งในรายการvalidate:"oneof=dev staging prod"คั่นด้วยเว้นวรรค ห้ามใช้ comma
oneofciเหมือน oneof แต่ไม่สนตัวพิมพ์validate:"oneofci=debug info warn error"DEV · PrOd ผ่านหมด
hostname|ip| = OR — เป็นอย่างใดอย่างหนึ่งก็ผ่านvalidate:"hostname|ip"underscore และ host:port ตก

ข้อความ error จริง — คืนครบทุก field พร้อมกัน

=== err.Error() ===
Key: 'App.Name' Error:Field validation for 'Name' failed on the 'required' tag
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag
Key: 'App.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
Key: 'App.Timeout' Error:Field validation for 'Timeout' failed on the 'gte' tag
=== count ===
len: 5

รูปแบบเป๊ะทุกช่องว่าง — มีช่องว่างหลัง Key: แต่ไม่มีหลัง Error: · ห้าบรรทัดนี้คั่นด้วย \n ที่ ValidationErrors.Error() ใส่ให้เอง ไม่ได้มาจาก errors.Join

oneof กับ hostname|ip — ผลจริงทีละค่า

---- oneofci=Dev Staging PROD ----
oneofci "dev"    -> <nil>
oneofci "DEV"    -> <nil>
oneofci "prod"   -> <nil>
oneofci "PrOd"   -> <nil>
oneofci "qa"     -> Key: 'C.Env' Error:Field validation for 'Env' failed on the 'oneofci' tag

hostname|ip "localhost"          -> <nil>
hostname|ip "api.example.com"    -> <nil>
hostname|ip "127.0.0.1"          -> <nil>
hostname|ip "::1"                -> <nil>
hostname|ip "192.168.1.300"      -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "not a host!"        -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip ""                   -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "db_server"          -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "example.com:5432"   -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
⚠ hostname ปฏิเสธ underscore และปฏิเสธ host:port

db_server ตก และ example.com:5432 ก็ตก · ถ้าใน docker-compose.yml ตั้งชื่อ service ว่า db_server หรือใน .env เก็บ host พร้อม port ไว้ในตัวแปรเดียว จะ validate ไม่ผ่านทันที
ชื่อ tag ใน error พิมพ์เป็น 'hostname|ip' ทั้งก้อน ไม่ได้แยกว่าตกข้อไหน

⚠ ใช้ comma ใน oneof = panic ตอน request แรก
// Env string `validate:"oneof=dev,staging,prod"`   ← ผิด
before Struct()
panic: Undefined validation function 'staging' on field 'Env'

goroutine 1 [running]:
github.com/go-playground/validator/v10.(*Validate).parseFieldTagsRecursive(...)
	.../validator/v10@v10.30.3/cache.go:311 +0x9e4
github.com/go-playground/validator/v10.(*Validate).extractStructCache(...)
	.../cache.go:156 +0x454
github.com/go-playground/validator/v10.(*Validate).Struct(...)
	.../validator_instance.go:347
exit status 2

สองอย่างที่ต้องจำ:
① ไม่ได้ panic ตอน validator.New() — panic ตอนเรียก validate.Struct() ครั้งแรก (ตอนสร้าง struct cache) · โปรแกรม go build ผ่าน start ขึ้นได้ แล้วไปตายตอน request แรก
② ข้อความไม่มีคำว่า oneof เลย — เพราะ comma ถูกอ่านเป็นตัวคั่น tag ทำให้ staging กลายเป็นชื่อ validator ที่ไม่มีอยู่ · หาไม่เจอว่าเกิดจากอะไรถ้าไม่รู้ล่วงหน้า

⚠ gte=1 เปล่า ๆ บน time.Duration = 1 นาโนวินาที
--- gte=1 (ไม่มีหน่วย) ---
gte=1 with 1ns  -> <nil>
gte=1 with 0    -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gte' tag
gte=1 with 500ms-> <nil>          ← ผ่านฉลุย

--- gte=1s,lte=30s (มีหน่วย) ---
0s     -> Key: 'B.T' Error:Field validation for 'T' failed on the 'gte' tag
1s     -> <nil>
15s    -> <nil>
31s    -> Key: 'B.T' Error:Field validation for 'T' failed on the 'lte' tag

จะคุมเป็นวินาที ต้องเขียนหน่วยเสมอ — gte=1s ไม่ใช่ gte=1 · ทั้ง gte และ lte เป็น inclusive (1s พอดีผ่าน · 31s ตกเพราะเกิน 30s)

WithRequiredStructEnabled() ต่างยังไง

--- nested struct (ไม่ใช่ pointer) ---
plain  / zero nested struct  -> <nil>
opt    / zero nested struct  -> Key: 'Cfg.DB' Error:Field validation for 'DB' failed on the 'required' tag
plain  / filled nested       -> <nil>
opt    / filled nested       -> <nil>
--- pointer nested, nil ---
plain -> Key: 'Cfg2.DB' Error:Field validation for 'DB' failed on the 'required' tag
opt   -> Key: 'Cfg2.DB' Error:Field validation for 'DB' failed on the 'required' tag

ต่างกันจุดเดียวเป๊ะ: required บน nested struct ที่ไม่ใช่ pointer · ไม่ใส่ option = tag นั้นถูกเมิน · ใส่ option = zero struct ตก · ส่วน *DB (pointer) ไม่เกี่ยว ตกทั้งสองแบบอยู่แล้ว · doc comment ในซอร์สเขียนว่า “WithRequiredStructEnabled enables required tag on non-pointer structs to be applied instead of ignored.”

แกะ error ทีละ field

var ve validator.ValidationErrors
if errors.As(err, &ve) {
	for _, fe := range ve {
		fmt.Println(fe.Namespace(), fe.Field(), fe.Tag(), fe.Param(), fe.Value())
	}
}
errors.As -> true len: 4
Namespace="Cfg.Name"    Field="Name"    Tag="required" Param=""      Kind=string Type=string        Value=""
Namespace="Cfg.Env"     Field="Env"     Tag="oneof"    Param="dev prod" Kind=string Type=string     Value="qa"
Namespace="Cfg.Timeout" Field="Timeout" Tag="gte"      Param="1s"    Kind=int64  Type=time.Duration Value=100000000
Namespace="Cfg.DB.Port" Field="Port"    Tag="lte"      Param="65535" Kind=int    Type=int           Value=99999
เมธอดคืนอะไรเอาไปทำอะไร
Namespace()Cfg.DB.Port — ทางเดินเต็มรวม struct ซ้อนบอกผู้ใช้ว่าผิดที่ field ไหน
Field()Port — ชื่อ field เปล่า ๆข้อความสั้น ๆ
Tag()lte — กฎที่ตกmap ไปเป็นข้อความภาษาไทย
Param()65535 · dev prodบอกว่าค่าที่ยอมรับคืออะไร
Value()ค่าที่ส่งมาจริงโชว์ให้เห็นว่าพิมพ์อะไรผิด

สองอย่างที่เห็นจากผลรัน: ① Value() ของ time.Duration คืน any ที่มี dynamic type เป็น time.Duration — พิมพ์ด้วย %v / %s / fmt.Sprint ได้ 100ms อ่านรู้เรื่องอยู่แล้ว · เลข 100000000 (int64 นาโนวินาที) โผล่ก็ต่อเมื่อใช้ %d หรือ %#v — ซึ่งเป็น verb ที่บล็อกข้างบนใช้อยู่พอดี · ② Namespace ของ nested field เป็น Cfg.DB.Port — validator ไล่เข้า struct ซ้อนให้เองโดยไม่ต้องใส่ tag อะไรที่ field DB

// รันจริง — validator v10.30.3 + go1.26.5 · fe.Value() ของ field Timeout
dynamic type = time.Duration
%v     -> 100ms
%s     -> 100ms
Sprint -> 100ms
%#v    -> 100000000
%d     -> 100000000

ที่ต้องระวังจริง ๆ คือตอน type assert — fe.Value().(int64) panic (dynamic type เป็น time.Duration ไม่ใช่ int64) · เขียน fe.Value().(time.Duration) แล้วพิมพ์ตรง ๆ ได้เลย

errors.Join — คั่นด้วยอะไรกันแน่

Error() = "alpha\nbeta\ngamma"
contains \n: 16
errors.Is(j,b) -> true
Unwrap() []error len: 3
errors.Join with nils: true true
พฤติกรรมผลจริง
ตัวคั่นระหว่าง error\n เปล่า ๆ ไม่มีช่องว่าง
มี \n ปิดท้ายไหมไม่มี — "alpha\nbeta\ngamma" ยาว 16 = 5+1+4+1+5
errors.Is ทะลุถึงตัวในได้
Unwrap() []errorมี — วนดูทีละตัวได้
errors.Join(nil, a, nil)คืน error (ข้าม nil ให้)
errors.Join(nil, nil)คืน nil
// ยัด ValidationErrors เข้า Join แล้วยังดึงกลับมาได้
errors.As through Join -> true 4
jv.Error() = "config load failed\nKey: 'Cfg.Name' Error:Field validation for 'Name' failed on the 'required' tag\nKey: 'Cfg.Env' Error:...
⚠ อย่าเข้าใจผิดว่า errors.Join เป็นคนจัดบรรทัดให้ validator

ValidationErrors.Error() คั่นบรรทัดด้วย \n ให้อยู่แล้วตั้งแต่ต้น · errors.Join ใส่ \n ให้แค่ระหว่าง error คนละก้อน เท่านั้น (ในตัวอย่างข้างบนคือระหว่าง "config load failed" กับก้อนของ validator)

Humanize — แปลง error ก้อนใหญ่ให้คนอ่านรู้เรื่อง

// internal/config/humanize.go
func Humanize(err error) string {
	var joined interface{ Unwrap() []error }
	errs := []error{err}
	if errors.As(err, &joined) {
		errs = joined.Unwrap()
	}

	lines := []string{"invalid configuration:"}
	for _, e := range errs {
		var ve validator.ValidationErrors
		if errors.As(e, &ve) {
			for _, fe := range ve {
				lines = append(lines, fmt.Sprintf("  - %s: ผิดกฎ %q", fe.Namespace(), fe.Tag()))
			}
			continue
		}
		lines = append(lines, "  - "+e.Error())
	}
	return strings.Join(lines, "\n")
}
$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= go run ./cmd/api
FATAL load configuration:
invalid configuration:
  - App.Port: ผิดกฎ "lte"
  - Logging.Level: ผิดกฎ "oneofci"
  - Database.PostgresDSN: ผิดกฎ "required"
exit status 1

ลำดับบรรทัดคาดเดาได้เสมอ — errors.Join เรียงตามลำดับที่เราใส่ (App → Logging → Database) และในแต่ละ struct เรียงตามลำดับ field

⚠ สองเรื่องที่ต้องรู้เกี่ยวกับ output ก้อนบน

① exit status 1 ไม่ใช่ข้อความของโปรแกรม — go run พิมพ์เองเมื่อ process ลูกจบด้วยโค้ดที่ไม่ใช่ 0 · build เป็น binary แล้วรันตรง ๆ จะไม่มีบรรทัดนี้

② Humanize มีรอยรั่ว — ตอน ParseAs พังจริง (ไม่ใช่ validation) พาดหัวยังขึ้น invalid configuration: และ prefix parse environment: env: หายไป เพราะ errors.As(err, &joined) วิ่งทะลุ fmt.Errorf ไปเจอ env.AggregateError ที่บังเอิญมี Unwrap() []error เหมือนกัน:

$ env APP_PORT=abc ./api
time=2026-08-22T10:15:12.112+07:00 level=ERROR msg="load configuration"
invalid configuration:
  - parse error on field "Port" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
ใช้ singleton — วัดจริงแล้วต่างกัน 72 เท่า
BenchmarkSingleton-10      	 8658763	       120.6 ns/op	      48 B/op	       1 allocs/op
BenchmarkNewEachTime-10    	  140612	      8638 ns/op	   16229 B/op	     224 allocs/op
// Apple M4, go1.26.5

doc ของไลบรารีเขียนไว้ว่า “Validator is designed to be thread-safe and used as a singleton instance. It caches information about your struct and validations … Using multiple instances neglects the benefit of caching.”
เหตุผลไม่ใช่แค่แคช struct — New() ต้องคัดลอกตาราง built-in validator ทั้งชุดใหม่ทุกครั้งด้วย · ประกาศเป็น package-level var ครั้งเดียวจบ

7 · โครง internal/config ที่ก็อปไปใช้ได้

api/ ├── cmd/ │ └── api/ │ └── main.go ← เรียก config.Load() ที่นี่ที่เดียว ├── internal/ │ └── config/ │ ├── config.go ← Load() · Validate() ที่รวมทุก section │ ├── app.go ← APP_NAME · APP_ENV · APP_PORT · APP_SHUTDOWN_TIMEOUT │ ├── logging.go ← LOG_LEVEL · LOG_FORMAT │ ├── database.go ← POSTGRES_DSN · DB_* │ ├── humanize.go ← แปลง error เป็นข้อความที่คนอ่านรู้เรื่อง │ └── config_test.go ← เทสต์ของ config เอง ├── .env ← ค่าจริงของเครื่องเรา — ห้าม commit └── .env.example ← โครงเปล่า ๆ ไว้ให้เพื่อนก๊อป — commit ได้

config.go

package config

import (
	"errors"
	"fmt"

	"github.com/caarlos0/env/v11"
	"github.com/go-playground/validator/v10"
)

// ใช้ตัวเดียวทั้ง package — validator แคชข้อมูล struct ไว้ข้างใน
var validate = validator.New(validator.WithRequiredStructEnabled())

type Config struct {
	App      App
	Logging  Logging
	Database Database
}

func Load() (Config, error) {
	cfg, err := env.ParseAs[Config]()
	if err != nil {
		return Config{}, fmt.Errorf("parse environment: %w", err)
	}

	// ไม่ wrap ด้วย fmt.Errorf — errors.Join จัดบรรทัดไว้แล้ว
	if err := cfg.Validate(); err != nil {
		return Config{}, err
	}

	return cfg, nil
}

// รวม error ของ "ทุก section" มาแสดงทีเดียว
// เพิ่ม Redis? สร้าง redis.go + เติม 1 บรรทัดตรงนี้
func (cfg Config) Validate() error {
	return errors.Join(
		cfg.App.Validate(),
		cfg.Logging.Validate(),
		cfg.Database.Validate(),
	)
}
⚠ สามไฟล์ข้างล่างตัด package + import ออกให้อ่านง่าย

app.go · logging.go · database.go อยู่ใน package config เดียวกับ config.go — ก็อปไปวางดิบ ๆ จะเจอตามลำดับนี้:

$ go build ./...
internal/config/app.go:1:1: expected 'package', found 'type'

# เติม package config แล้วยังไม่พอ
$ go build ./...
internal/config/app.go:7:18: undefined: time
internal/config/app.go:10:45: undefined: fmt

ที่ต้องเติมเองคือ: app.go → "fmt" + "time" · database.go → "time" · logging.go → ไม่ต้องเติมอะไร · เติมครบแล้ว go build ./... ผ่านและ sizeof(config.Config) = 120 bytes ตามที่โชว์ข้างล่าง

app.go

type App struct {
	Name            string        `env:"APP_NAME" envDefault:"wongnok" validate:"required"`
	Env             string        `env:"APP_ENV" envDefault:"development" validate:"required,oneof=development staging production"`
	Port            int           `env:"APP_PORT" envDefault:"8080" validate:"gte=1,lte=65535"`
	ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s" validate:"required"`
}

func (app App) Addr() string       { return fmt.Sprintf(":%d", app.Port) }
func (app App) IsProduction() bool { return app.Env == "production" }

func (app App) Validate() error { return validate.Struct(app) }

logging.go

type Logging struct {
	Level  string `env:"LOG_LEVEL" envDefault:"info" validate:"required,oneofci=debug info warn error"`
	Format string `env:"LOG_FORMAT" envDefault:"json" validate:"required,oneofci=json text"`
}

func (logging Logging) Validate() error { return validate.Struct(logging) }

Level ต้องใช้ oneofci ไม่ใช่ oneof — ไม่งั้น tag ที่โผล่ใน error จะเป็น 'oneof' แล้วข้อความจะไม่ตรงกับที่หัวข้อ 6 โชว์ไว้

database.go

type Database struct {
	PostgresDSN     string        `env:"POSTGRES_DSN" validate:"required"`
	MaxOpenConns    int           `env:"DB_MAX_OPEN_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
	MaxIdleConns    int           `env:"DB_MAX_IDLE_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
	ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m" validate:"required,gt=0"`
}

func (database Database) Validate() error { return validate.Struct(database) }
⚠ ทำไม ConnMaxLifetime ต้องมี gt=0 ด้วย

เวอร์ชันแรกของไฟล์นี้เขียนเป็น if ยาว ๆ:

if database.ConnMaxLifetime <= 0 {
	return errors.New("DB_CONN_MAX_LIFETIME must be greater than 0")
}

พอ refactor เป็น validate:"required" เฉย ๆ ความสามารถหายไปจริง:

########## [ก่อน refactor] DB_CONN_MAX_LIFETIME=-5s
Validate : FAILED ->
DB_CONN_MAX_LIFETIME must be greater than 0
########## [หลัง refactor] DB_CONN_MAX_LIFETIME=-5s
Validate : OK                              ← ค่าติดลบผ่านฉลุย

required บน time.Duration แปลว่า “ห้ามเป็น zero” เท่านั้น ไม่ใช่ “ต้องมากกว่า 0” · ต้องเขียน required,gt=0 ถึงจะเทียบเท่าของเดิม
เรื่องเดียวกันนี้ใช้กับ App.ShutdownTimeout ด้วย — APP_SHUTDOWN_TIMEOUT=-5s หลุดทั้ง ParseAs และ validator

config_test.go — เทสต์ config โดยไม่ต้องมี .env

func TestLoad_Defaults(t *testing.T) {
	t.Setenv("POSTGRES_DSN", "postgres://u:p@localhost:5432/db")
	// t.Setenv คืนค่า env เดิมให้อัตโนมัติเมื่อจบเทสต์
	cfg, err := config.Load()
	// ...
}
$ go test ./internal/config/ -v
=== RUN   TestLoad_Defaults
--- PASS: TestLoad_Defaults (0.00s)
=== RUN   TestLoad_ThreeErrorsAtOnce
--- PASS: TestLoad_ThreeErrorsAtOnce (0.00s)
=== RUN   TestLoad_ReturnsZeroValueOnError
--- PASS: TestLoad_ReturnsZeroValueOnError (0.00s)
PASS
ok  	wongnok/api/internal/config	0.572s

ทำไม Load() คืน Config ไม่ใช่ *Config

sizeof(config.Config) = 120 bytes
sizeof(*config.Config) = 8 bytes
[value ] err != nil? true | cfgVal.App.Port = 0 | cfgVal.App.Addr() = ":0"
[ptr   ] err != nil? true | cfgPtr == nil? true
[ptr   ] panic ตอนอ่าน cfgPtr.App.Port: runtime error: invalid memory address or nil pointer dereference
ก่อนเรียก              : Port=8080 Addr=":8080"
หลัง handlerA(value)   : Port=8080 Addr=":8080"
หลัง handlerB(pointer) : Port=1 Addr=":1"  <-- validate ผ่านไปแล้ว แต่ค่าเปลี่ยนได้
คืน Config (value)คืน *Config (pointer)
ตอน errorได้ Config{} ที่ยังเรียกเมธอดได้ (Addr() = ":0")ได้ nil — ลืมเช็ค err แล้ว panic ทันที
ส่งต่อให้ handlerแต่ละคนได้สำเนา แก้แล้วไม่กระทบตัวจริงใครก็แก้ค่าที่ validate ผ่านแล้วได้
ราคาที่จ่ายคัดลอก 120 bytesคัดลอก 8 bytes

Load() ถูกเรียกครั้งเดียวตอน start — 120 bytes ไม่ใช่เรื่อง · เหตุผลที่หนักแน่นกว่าคือแถวกลาง: ค่าที่ผ่าน validate แล้วต้อง valid ตลอดอายุโปรแกรม

8 · ท่อลำเลียงค่าทั้งเส้น

.env ← ไฟล์ text ธรรมดา (local dev เท่านั้น) │ │ godotenv ← ยกค่าเข้าไปเป็น env · ของที่มีอยู่แล้วชนะ ▼ Environment Variables ← ทุกอย่างเป็น "string" ล้วน │ │ caarlos0/env ← env:"KEY" · envDefault · แปลง type · ,required ▼ Config Struct ← int / time.Duration / slice / map แล้ว แต่ยัง "ไม่รู้ว่าค่าสมเหตุผลไหม" │ │ validator/v10 ← validate:"gte=1,lte=65535" · oneof · required ▼ Valid Config ← ผ่านด่านแล้ว เชื่อได้ทุก field │ ▼ Application ← gorm.Open · http.ListenAndServe
ด่านจับอะไรได้จับอะไรไม่ได้
godotenvไฟล์ .env พัง syntaxชื่อ key แปลก ๆ (123INVALID ผ่าน) · ค่าผิดความหมาย
caarlos0/envแปลง type ไม่ได้ (APP_PORT=abc) · ไม่มี env ที่ ,requiredค่าที่ type ถูกแต่ไร้สาระ — APP_PORT=99999 ผ่านสบาย
validatorค่านอกช่วง · ค่าไม่อยู่ในรายการ · ค่าว่างค่าติดลบ ถ้าใช้แค่ required (ต้องเติม gt=0)

พิสูจน์ว่าด่าน 2 ปล่อยผ่านจริง แล้วด่าน 3 รับช่วง

$ env POSTGRES_DSN="postgres://u:p@localhost:5432/db" APP_PORT=99999 go run ./cmd/probe
ParseAs  : OK -> App.Port=99999 App.Env="development" Log.Level="info" Shutdown=10s DSN="postgres://u:p@localhost:5432/db"
Validate : FAILED ->
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag

$ env POSTGRES_DSN= go run ./cmd/probe
ParseAs  : OK -> App.Port=8080 ... DSN=""
Validate : FAILED ->
Key: 'Database.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag

$ env POSTGRES_DSN="..." APP_ENV=prodction go run ./cmd/probe
ParseAs  : OK -> App.Env="prodction" ...
Validate : FAILED ->
Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag

$ env POSTGRES_DSN="..." LOG_LEVEL=BANANA go run ./cmd/probe
ParseAs  : OK -> Log.Level="BANANA" ...
Validate : FAILED ->
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag

$ env POSTGRES_DSN="..." APP_SHUTDOWN_TIMEOUT=0s go run ./cmd/probe
ParseAs  : OK -> Shutdown=0s ...
Validate : FAILED ->
Key: 'App.ShutdownTimeout' Error:Field validation for 'ShutdownTimeout' failed on the 'required' tag

ห้าเคสนี้ ParseAs ผ่านหมด — พิมพ์ prodction ตกตัว u ก็ยังเป็น string ที่ถูกต้องในสายตา env · ตัวที่จับได้คือ validator ล้วน ๆ

ตายตั้งแต่ start ไม่ใช่ตอน request แรก

$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= ./api & sleep 2
--- ps เช็คว่า process ยังอยู่ไหม ---
process ตายไปแล้ว
--- lsof :8080 ---
(ไม่มี process ไหน listen :8080)
--- curl /healthz ---
http_code=000
curl exit=7
EXIT_CODE=1

พิสูจน์ครบสามชั้น: process ตาย · ไม่มีอะไร bind :8080 · curl ต่อไม่ติด (exit 7 = Failed to connect) · exit code จริงคือ 1

$ # config ถูกต้อง — เดินจนสุดสาย
--- curl /healthz ---
ok
http_code=200
--- stderr ของ server ---
{"time":"2026-08-22T10:13:43.048067+07:00","level":"INFO","msg":"configuration loaded","app":"wongnok","env":"development","addr":":8080"}
{"time":"2026-08-22T10:13:43.084594+07:00","level":"INFO","msg":"database connected"}
{"time":"2026-08-22T10:13:43.085393+07:00","level":"INFO","msg":"server starting","addr":":8080"}

ลำดับใน run() ที่ทำให้ได้ log แบบนี้: logger ชั่วคราว (slog TextHandler) → godotenv.Load() → config.Load() → logger ตัวจริงที่สร้างจาก cfg.Logging (เลยกลายเป็น JSON ตั้งแต่บรรทัดแรก) → gorm.Open + SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime + Ping → ListenAndServe

9 · JWT — โครงสร้าง · golang-jwt/v5 · error ทุกเคส

สามส่วน คั่นด้วยจุด

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6... . KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 |________ header ________________|___|______ payload _________|___|__________ signature ____________________| ^ ^ จุดคั่นที่ 1 จุดคั่นที่ 2 header = base64url(JSON) บอกว่า token นี้เซ็นด้วยวิธีไหน payload = base64url(JSON) ข้อมูลที่อยากพกไปกับ token — เรียกว่า claims signature = HMAC หรือ RSA ของ (header + "." + payload) ตัวที่ทำให้แก้เนื้อในไม่ได้โดยไม่ถูกจับ
ส่วนdecode ได้โดยไม่มี key ไหมเนื้อจริงของ token ตัวอย่าง
headerได้{"alg":"HS256","typ":"JWT"}
payloadได้{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
signaturedecode ได้ แต่ตรวจไม่ได้ถ้าไม่มี key—
$ echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | tr '_-' '/+' | base64 -d
{"alg":"HS256","typ":"JWT"}

# payload — ไม่ต้องมี secret เลย
{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}

# ทำซ้ำด้วย Go: jwt.NewParser().ParseUnverified (ไม่ส่ง secret เข้าไป)
err=<nil>
alg=HS256 typ=JWT
claims=map[admin:true iat:1.516239022e+09 name:John Doe sub:1234567890]
จำนวนส่วนของ token=3
⚠ base64 -d ของ macOS ตัดท้ายทิ้งถ้าความยาวไม่หารด้วย 4 ลงตัว

คำสั่งข้างบนใช้ได้กับ header เพราะมันยาว 36 ตัวพอดี (หาร 4 ลงตัว) · พอเอาคำสั่งเดิมไป decode payload ซึ่งยาว 91 ตัว จะขาดท้าย:

$ echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' | tr '_-' '/+' | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true,"iat":151623902     ← ขาด 2 ตัวท้าย ไม่มี error ด้วย

ใช้คำสั่งนี้แทน — เติม padding ให้เอง แล้วได้ครบทุกครั้ง:

$ python3 -c "import base64,sys;print(base64.urlsafe_b64decode(sys.argv[1]+'='*(-len(sys.argv[1])%4)).decode())" <segment>
{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
⚠ ห้ามใส่ความลับใน payload — เด็ดขาด

err=<nil> ทั้งที่ไม่ได้ส่ง secret เข้าไปสักตัว และเห็น admin:true ชัด ๆ · signature กันการแก้ไข ไม่ได้กันการอ่าน
(iat 1516239022 = 2018-01-18 01:30:22 UTC)

⚠ secret ของ token ตัวอย่างบน jwt.io เปลี่ยนไปแล้ว
your-256-bit-secret                      reGQzG3OKdoIMWLDKOZ4TICJit3EW69cQE72E2CfzRE  no
secret                                   39jkN-bckg4fbZQEb0xHIxzYL9qI_g4c4WyzEYNHZok  no
a-string-secret-at-least-256-bits-long   KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30  MATCH
target:                                  KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

# ยืนยันซ้ำด้วย jwt/v5
secret="your-256-bit-secret"                    -> FAIL: token signature is invalid: signature is invalid
secret="a-string-secret-at-least-256-bits-long" -> OK (signature ผ่าน)

secret จริงคือ a-string-secret-at-least-256-bits-long ไม่ใช่ your-256-bit-secret (ของเก่า) · ลองตามด้วยตัวเก่าจะขึ้น signature is invalid แล้วงงว่าตัวเองพิมพ์ผิดตรงไหน

โครงที่ก็อปไปใช้ได้

import (
	"time"                              // ขาดไม่ได้ — โค้ดข้างล่างใช้ time.Now()

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

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

// ---------- สร้าง ----------
claims := jwt.MapClaims{
	"sub":  "42",
	"name": "somchai",
	"role": "admin",
	"iat":  time.Now().Unix(),
	"exp":  time.Now().Add(time.Hour).Unix(),   // ต้องใส่เอง! ดูกล่องเตือน
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)

// ---------- ตรวจ ----------
tok, err := jwt.Parse(token,
	func(t *jwt.Token) (any, error) { return secret, nil },
	// algorithm confusion = attacker สลับ alg ใน header ให้เราหยิบ "คีย์ผิดประเภท" ไปตรวจ (ดูท้ายหัวข้อ)
	jwt.WithValidMethods([]string{"HS256"}),   // กัน algorithm confusion
	jwt.WithExpirationRequired(),                 // บังคับว่าต้องมี exp
)
=== 3) สร้าง JWT เอง (HS256) ===
token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODczNzE5MjUsImlhdCI6MTc4NzM2ODMyNSwibmFtZSI6InNvbWNoYWkiLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.MTGaDfheYUXIvMe-j3oO8iTOvdgRPLaizc0cHV_ybEQ
len   = 185 byte

--- 3b) parse กลับ + verify ---
OK  valid=true claims=map[exp:1.787371925e+09 iat:1.787368325e+09 name:somchai role:admin sub:42]
⚠ MapClaims decode ตัวเลขเป็น float64 เสมอ

เห็น exp:1.787371925e+09 ไหม — นั่นคือ float64 ไม่ใช่ int · เขียน claims["sub"].(int) หรือ .(int64) จะ panic · ต้อง .(float64) แล้วค่อยแปลง หรือใช้ custom claims struct

error จริงของทุกเคส

เคสerr ที่ได้จริง
token หมดอายุ (exp ผ่านไปแล้ว)token has invalid claims: token is expired
signature ผิด (แก้ตัวอักษรเดียว)token signature is invalid: signature is invalid
alg ไม่ตรง (token=HS256 ยอมรับแค่ RS256)token signature is invalid: signing method HS256 is invalid
alg=none + WithValidMethods([HS256])token signature is invalid: signing method none is invalid
alg=none + ไม่มี WithValidMethodstoken signature is invalid: token is unverifiable: 'none' signature type is not allowed
ไม่มี exp + WithExpirationRequired()token has invalid claims: token is missing required claim: exp claim is required
ไม่มี exp เฉย ๆ (default)<nil> — ผ่าน! ดูกล่องเตือน
nbf อยู่ในอนาคตtoken has invalid claims: token is not valid yet
aud ไม่ตรงtoken has invalid claims: token has invalid audience
WithIssuer() แต่ token ไม่มี isstoken has invalid claims: token is missing required claim: iss claim is required
token มี 2 ส่วน / เป็นข้อความมั่ว / ว่างเปล่าtoken is malformed: token contains an invalid number of segments (เหมือนกันทั้งสามเคส)
algorithm confusion RS256→HS256 (ไม่มี WithValidMethods)token signature is invalid: key is of invalid type: HMAC verify expects []byte
⚠ token ที่ไม่มี exp = token ตลอดชีพ
token ไม่มี exp เลย (default)                        err = <nil>
token ไม่มี exp + WithExpirationRequired()           err = token has invalid claims: token is missing required claim: exp claim is required

คำพูดที่ว่า “JWT ดีกว่า Basic Auth เพราะมีวันหมดอายุ” จริงก็ต่อเมื่อ เราใส่ exp เองตอนเซ็น · golang-jwt/v5 ไม่ได้ใส่ให้ และไม่บ่นถ้าไม่มี · ฝั่งตรวจควรใส่ jwt.WithExpirationRequired() เสมอ

WithLeeway เป็นดาบสองคม — default leeway = 0 · ใส่ jwt.WithLeeway(60*time.Second) แล้ว token ที่หมดอายุไป 20 วินาที จะผ่าน (err=<nil>) · มีไว้เผื่อนาฬิกาสองเครื่องคลาดกัน แต่ก็แปลว่าเราตั้งใจยอมรับ token ที่หมดอายุแล้ว

ErrSignatureInvalid กับ ErrTokenSignatureInvalid ไม่เหมือนกัน —
เคส signature ผิด: errors.Is เป็น true ทั้งคู่
เคส alg ไม่ตรง: ErrSignatureInvalid = false แต่ ErrTokenSignatureInvalid = true
จะเช็คด้วย errors.Is ให้ใช้ jwt.ErrTokenSignatureInvalid ตัวที่ครอบคลุมกว่า

alg=none ทะลุได้จริงไหม

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) keyfunc คืน UnsafeAllowNoneSignatureType -> err = <nil>      ← ทะลุ!
D) ยกระดับ user เป็น admin ผ่าน alg=none -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed  valid = false

golang-jwt/v5 บล็อก alg=none ให้ตั้งแต่ค่าเริ่มต้นแม้ไม่ใส่ WithValidMethods (เคส B) · ทางเดียวที่ทะลุคือ dev เขียน keyfunc คืน jwt.UnsafeAllowNoneSignatureType เอง (เคส C)
ดังนั้นเหตุผลจริงที่ต้องใส่ WithValidMethods คือ กัน algorithm confusion + ให้ error ชัด ไม่ใช่ “กัน alg=none”

// algorithm confusion: attacker เอา RSA public key (ที่เปิดเผยอยู่แล้ว) มาเซ็นแบบ HS256
attacker เซ็น HS256 ด้วย public key: err=<nil>
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

เคส A รอดเพราะ type ไม่ match โดยบังเอิญ ไม่ใช่เพราะมันเช็ค alg — ถ้า keyfunc คืน []byte ตอนไหนก็ทะลุ · เคส B กันแบบตั้งใจ · ใส่ WithValidMethods เสมอ

Basic Auth vs JWT — ทำไมถึงย้าย

Basic AuthJWT
header ที่ส่งAuthorization: Basic c29tY2hhaTpwQHNzdzByZA==Authorization: Bearer eyJhbGci…
ข้างในมีอะไรsomchai:p@ssw0rd — รหัสผ่านจริง{"sub":…,"exp":…,"role":…} — ไม่มีรหัสผ่าน
ส่งบ่อยแค่ไหนทุก request (header เดิมเป๊ะ)ทุก request เหมือนกัน
decode ได้โดยไม่มี keyได้ (base64 เฉย ๆ)ได้ (base64url เฉย ๆ)
มีวันหมดอายุไม่มีที่ให้ใส่มี — ถ้าเราใส่ exp เอง
มี role / claimsไม่มีมี
ต้องถาม DB ทุก requestต้องไม่ต้อง (signature บอกว่าเชื่อได้)
$ # ยิง 3 ครั้ง — header ตัวเดิมเป๊ะทุกครั้ง
[server] GET /foods  Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true

$ # decode กลับได้ทันทีโดยไม่ต้องมี key
somchai:p@ssw0rd
⚠ r.BasicAuth() คืน ok=true ทั้งที่รหัสผิด
$ # ยิงด้วยรหัสผิด
[server] GET /foods  Authorization: "Basic c29tY2hhaTp3cm9uZw=="
[server] r.BasicAuth() -> user="somchai" pass="wrong" ok=true

ok แปลว่า “แกะ header สำเร็จ” ไม่ใช่ “รหัสถูก” · เขียน if ok { ให้ผ่าน } คือช่องโหว่เต็ม ๆ ต้องเทียบ user/pass เองเสมอ (และควรใช้ subtle.ConstantTimeCompare)

ราคาที่จ่าย — JWT ใหญ่กว่า session id มาก

แบบbyteเทียบ session hex
JWT minimal (sub+exp)1203.8 เท่า
JWT typical (6 claims)2257.0 เท่า
JWT fat (roles + permissions + ชื่อไทย)55317.3 เท่า
session id 128-bit hex32—
session id 256-bit base64url431.3 เท่า
header ที่ส่งจริง Authorization: Bearer <typical> = 247 byte
header ที่ส่งจริง Cookie: sid=<hex>              = 44 byte

ถ้ามี 1000 request: fat JWT กินแบนด์วิดท์ขาขึ้นเพิ่ม 521000 byte เทียบ session

fat JWT บวมเพราะ claims เยอะ แล้ว ชื่อไทยยิ่งแพงกว่าชื่ออังกฤษ ~4 เท่าต่อตัวอักษร — ภาษาไทยเป็น UTF-8 ตัวละ 3 byte แล้ว base64 ขยายอีก ×4/3 กลายเป็นตัวละ 4 byte ใน token · อย่ายัด permission ทั้งชุดลง token

encoding/json ของ Go ไม่ escape ภาษาไทยเป็น \uXXXX — ต่างจาก json.dumps ของ Python ที่ ensure_ascii=True เป็นค่าเริ่มต้น · ตรวจแล้วกับ golang-jwt/jwt/v5 v5.3.1:

json.Marshal -> {"name":"สมชาย ใจดี"}   len: 39 byte
มี \u escape ไหม: false
payload ที่ decode กลับจาก token: {"exp":…,"name":"สมชาย ใจดี","permissions":[…],…}
"สมชาย ใจดี" = 10 rune · 28 byte (UTF-8) · base64url แล้ว 38 byte

Swagger — บอก Swagger UI ว่ามีช่องใส่ token

// @securityDefinitions.apikey	BearerAuth
// @in							header
// @name						Authorization
// @description				ใส่ค่าเป็น "Bearer {token}"
=== securityDefinitions ที่ได้จริง (swag v1.16.4) ===
{
  "BearerAuth": {
    "description": "ใส่ค่าเป็น \"Bearer {token}\"",
    "type": "apiKey",
    "name": "Authorization",
    "in": "header"
  }
}
--- security ระดับ endpoint ---
[ { "BearerAuth": [] } ]
--- swagger version --- 2.0

swag v1 ออก Swagger 2.0 ซึ่งไม่มี type: http / scheme: bearer — จึงต้องใช้ apiKey และผู้ใช้ต้องพิมพ์คำว่า Bearer นำหน้าเองในช่อง Authorize · รายละเอียด annotation ครบชุดอยู่ที่ บทเรียน 41

10 · OAuth 2.0 — ตัวละคร 4 ตัว + Authorization Code Grant

OAuth 2.0 คือ framework สำหรับ มอบสิทธิ์แบบจำกัดขอบเขต (delegated authorization) — ให้แอปหนึ่งทำอะไรบางอย่างแทนเราได้ โดยไม่ต้องบอกรหัสผ่านของเราให้มันรู้

ตัวละครศัพท์ทางการในภาพของ Wongnok คือใครถืออะไรไว้
ผู้ใช้Resource Ownerคนที่นั่งอยู่หน้าจอรหัสผ่านของตัวเอง
แอปของเราClientwongnok API (Go/Gin)client_id + client_secret
คนออก tokenAuthorization ServerKeycloakฐานผู้ใช้ · หน้า login · private key ที่ใช้เซ็น
คนถือของResource ServerAPI ที่มีข้อมูลจริง (บางทีก็คือ wongnok API เอง)ข้อมูล + public key ไว้ตรวจ token

Authorization Code Grant — 8 ขั้น

User = ผู้ใช้ (Resource Owner) AuthSrv = Keycloak (Authorization Server) App = wongnok API (Client) ResSrv = API ที่มีข้อมูลจริง (Resource Server) User --> App 1. กดปุ่ม Login User <-- App 2. 302 Redirect ไปหน้า Login ของ Auth Server User --> AuthSrv 3. กรอก username / password ที่ Auth Server *** password ไม่เคยผ่าน App ของเราเลย *** App <-- AuthSrv 4. Redirect กลับพร้อม authorization code App --> AuthSrv 5. ส่ง code + client_secret แลก token *** ทำที่ backend แบบ server-to-server *** App <-- AuthSrv 6. คืน access_token (+ refresh_token) App --> ResSrv 7. เรียก API พร้อม Bearer token App <-- ResSrv 8. ตรวจสอบ token แล้วตอบข้อมูลกลับ
ขั้นเกิดอะไรทำไมต้องมีขั้นนี้
1ผู้ใช้กดปุ่ม Login ในแอปเราจุดเริ่ม — แอปยังไม่รู้ว่าเขาเป็นใคร
2แอปตอบ redirect ไปหน้า login ของ Auth Serverหน้ากรอกรหัสต้องเป็นของ Auth Server เท่านั้น
3ผู้ใช้กรอก username/password ที่ Auth Serverรหัสผ่านไม่เคยผ่านแอปของเราเลย — หัวใจของ OAuth
4Auth Server redirect กลับ พร้อม authorization codecode เป็นแค่ “คูปอง” ใช้ครั้งเดียว อายุสั้น ไม่ใช่ token
5แอปส่ง code + client_secret ไปแลก tokenทำที่ backend — เบราว์เซอร์ไม่มีทางเห็น client_secret
6Auth Server คืน access_token (+ refresh_token)ของจริงมาถึงมือแอป โดย code ที่เคยโผล่ใน URL ใช้ไม่ได้อีกแล้ว
7แอปเรียก API พร้อม Authorization: Bearer …ใช้ token แทนรหัสผ่าน
8Resource Server ตรวจ token แล้วตอบข้อมูลตรวจด้วย public key ได้เลย ไม่ต้องถาม Auth Server

ทำไมต้องแลกสองต่อ (code ก่อน แล้วค่อย token) — ขั้น 4 ค่าเดินทางผ่านแถบที่อยู่ของเบราว์เซอร์ ซึ่งเข้า access log · เข้า history · โผล่ใน Referer · ถ้าส่ง token ตรง ๆ ตรงนั้น token จะติดไปทุกที่
จึงส่งแค่ “คูปองใช้ครั้งเดียว อายุสั้น” ไปก่อน แล้วเอาไปแลกของจริงที่ backend ซึ่งต้องมี client_secretถึงจะแลกได้

⚠ ภาพ 8 ขั้นข้างบนคือ confidential client เท่านั้น

ขั้น 5 พึ่ง client_secret ที่เก็บไว้ที่ backend · ถ้า client เป็น public (SPA / mobile ที่ไม่มีที่ซ่อน secret) จะไม่มี client_secret ให้ใช้เลย — ต้องใช้ PKCE แทน:

ขั้น 2  App สุ่ม code_verifier แล้วส่ง code_challenge = BASE64URL(SHA256(code_verifier)) ไปกับ request
ขั้น 5  App ส่ง code + code_verifier ตัวจริง (แทน client_secret) ไปแลก token
        Auth Server แฮชแล้วเทียบกับ code_challenge ที่จำไว้ — ไม่ตรงก็ไม่ให้ token

Keycloak รองรับอยู่แล้ว — discovery ของตัวที่รันจริง (Keycloak 26.7.2) ประกาศไว้ว่า code_challenge_methods_supported = ["plain", "S256"] (ใช้ S256) · OAuth 2.1 บังคับ PKCE กับทุก client ไม่ใช่แค่ public แล้ว

โค้ด OIDC ฝั่ง Go — compile และรันจริงแล้ว

import (
	"github.com/coreos/go-oidc/v3/oidc"   // v3.20.0
	"golang.org/x/oauth2"                 // v0.36.0
)

provider, err := oidc.NewProvider(ctx, issuerURL)   // อ่าน discovery ให้เอง

conf := &oauth2.Config{
	ClientID:     clientID,
	ClientSecret: clientSecret,
	Endpoint:     provider.Endpoint(),                 // ไม่ต้อง hardcode URL
	RedirectURL:  "http://localhost:8080/auth/callback",
	Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
}

// ขั้น 2
url := conf.AuthCodeURL(state)

// ขั้น 5-6
tok, err := conf.Exchange(ctx, code)

// id_token ไม่ได้อยู่ใน tok.AccessToken — ต้องดึงแยก
rawIDToken, ok := tok.Extra("id_token").(string)

verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
var claims struct {
	Email             string `json:"email"`
	PreferredUsername string `json:"preferred_username"`
}
err = idToken.Claims(&claims)
$ go build ./... ; go vet ./...
BUILD_EXIT=0
VET_EXIT=0

$ go run ./cmd/probe      # ยิง issuer จริง (Google) ไม่ใช่แค่ compile
AuthURL  = https://accounts.google.com/o/oauth2/v2/auth
TokenURL = https://oauth2.googleapis.com/token
AuthCodeURL = https://accounts.google.com/o/oauth2/v2/auth?client_id=my-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email&state=state-abc123
Verify(ขยะ) ERROR = oidc: malformed jwt: go-jose/go-jose: compact JWS format must have three parts
คำสั่งลากอะไรมาด้วยจริง (module เปล่า · go1.26.5)
go get golang.org/x/oauth2@v0.36.0go: added golang.org/x/oauth2 v0.36.0 — บรรทัดเดียว
go get github.com/coreos/go-oidc/v3@v3.20.0go: added github.com/coreos/go-oidc/v3 v3.20.0 — บรรทัดเดียว เหมือนกัน
go mod tidy หลังเขียนโค้ดที่ import ทั้งสองตัวแล้วถึงจะโผล่ github.com/go-jose/go-jose/v4 v4.1.4 // indirect เข้ามาใน go.mod

cloud.google.com/go/compute/metadata จะไม่โผล่ในโปรเจกต์เรา — มันอยู่ใน go.mod ของ oauth2 เอง แต่ถูกใช้แค่ในแพ็กเกจย่อย golang.org/x/oauth2/google · ตราบใดที่ไม่ได้ import แพ็กเกจนั้น module graph pruning จะตัดทิ้ง · ตรวจแล้วกับ module เปล่า: grep cloud.google.com go.sum ได้ 0 บรรทัด
ข้อคิด: go get เฉย ๆ ไม่ได้บอกความจริงทั้งหมด — ดู go.mod หลัง go mod tidy ที่มีโค้ดจริงแล้วเท่านั้น

11 · Keycloak — compose · endpoint · components

Keycloak คือ Authorization Server แบบ open source ที่เรารันเองได้ — ทำหน้าที่เก็บผู้ใช้ · แสดงหน้า login · ออก token · ปิด session ให้เรา โดยที่เราไม่ต้องเขียนระบบเก็บรหัสผ่านเอง

compose.yml

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.7
    container_name: wongnok-keycloak
    restart: always
    command: ["start-dev", "--import-realm"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      # ฉบับ Post ลบ KC_HOSTNAME: localhost ออกแล้ว
      KC_HOSTNAME_STRICT: "false"
      KC_HTTP_ENABLED: "true"
    ports:
      - "8081:8080"
    volumes:
      # เก็บข้อมูลของ embedded database ไว้
      - wongnok-keycloak-data:/opt/keycloak/data

# ← บล็อกนี้ขาดไม่ได้ ไม่งั้น compose จะบอกว่า volume undefined
volumes:
  wongnok-keycloak-data:
$ docker-compose up -d
 Image quay.io/keycloak/keycloak:26.7 Pulled
 Network kc_default Created
 Volume kc_wongnok-keycloak-data Created
 Container wongnok-keycloak Created
 Container wongnok-keycloak Started

$ # รอจน ready
READY after ~4s (HTTP 200)

$ docker logs wongnok-keycloak | tail
2026-08-22 03:13:34,745 INFO  [org.keycloak.services] (main) KC-SERVICES0077: Created temporary admin user with username admin
2026-08-22 03:13:34,883 INFO  [io.quarkus] (main) Keycloak 26.7.2 on JVM (powered by Quarkus 3.33.3.1) started in 5.582s. Listening on: http://0.0.0.0:8080
บรรทัดใน composeทำอะไรจริง ๆ
image: …:26.7tag นี้มีจริง มี arm64 ด้วย และเป็น floating tag ที่ตอนนี้ชี้ไป 26.7.2
start-devโหมด dev — ใช้ H2 ไฟล์เดียวใน /opt/keycloak/data/h2
--import-realmไม่ทำอะไรเลย ถ้าไม่มีไฟล์ realm json อยู่ใน /opt/keycloak/data/import — และไม่ error ด้วย
KC_BOOTSTRAP_ADMIN_*สร้าง temporary admin user ให้ตอน start
KC_HOSTNAME: localhostฉบับ Post ลบทิ้งแล้ว — ถ้าปักหมุดไว้ issuer จะตอบเป็น localhost เสมอ แม้ถูกเรียกด้วยชื่อ service ใน compose → oidc.NewProvider() error
KC_HOSTNAME_STRICT: "false"ผลเท่ากับไม่ใส่ในโหมด start-dev · ตั้ง "true" โดยไม่มี hostname = คอนเทนเนอร์ตาย
8081:8080ข้างนอก 8081 · ข้างใน 8080 (เลี่ยงชนกับ API ของเราที่ 8080)
wongnok-keycloak-data:/opt/keycloak/dataทำให้ realm/user/group/role รอด restart จริง — ทดสอบแล้ว
⚠ สามจุดที่ compose ในสไลด์ทำให้เข้าใจผิด
$ docker logs wongnok-keycloak | head -20
2026-08-22 03:13:30,102 INFO  [org.keycloak.url.HostnameV2ProviderFactory] (main) If hostname is specified, hostname-strict is effectively ignored

$ docker logs wongnok-keycloak 2>&1 | grep -i -E "import|realm"
2026-08-22 03:13:34,071 INFO  [org.keycloak.services] (main) KC-SERVICES0050: Initializing master realm
# ← ไม่มีบรรทัด import สักบรรทัด

① KC_HOSTNAME_STRICT ถูกเมิน — Keycloak พิมพ์บอกเองในล็อก (ฉบับ Post แก้แล้ว ด้วยการลบ KC_HOSTNAME ทิ้ง บรรทัดเตือนนี้จึงหายไปจากล็อก)
② --import-realm เงียบ ๆ ไม่ทำอะไร เพราะไม่มีอะไร mount เข้า /opt/keycloak/data/import
③ ถ้าลอก compose มาแค่ส่วน services: จะ error ตั้งแต่ยังไม่ทันขึ้น — service "keycloak" refers to undefined volume wongnok-keycloak-data: invalid compose project (ตัดบล็อก volumes: ออกแล้วรัน docker-compose up -d จริง · exit code 1) — ต้องมี top-level volumes: ด้วย
และล็อกยังเตือนเองว่า “Running the server in development mode. DO NOT use this configuration in production.” — start-dev คือของ dev เท่านั้น

endpoint สำคัญ — จาก OIDC discovery

ศัพท์ที่โผล่ใน URL ก่อนจะถูกอธิบายเต็ม ๆ ในหัวข้อย่อยถัดไป: realm = พื้นที่แยกอิสระของแต่ละโปรเจกต์ (user/client/role ไม่ปนข้าม realm) · master คือ realm ที่ Keycloak สร้างให้เองตอน start — ของจริงเราจะสร้าง realm ชื่อ wongnok แยกออกมา ไม่ใช้ master ทำงาน

$ curl -s http://localhost:8081/realms/master/.well-known/openid-configuration
fieldค่าจริง (realm master)ใช้ตอนไหน
issuerhttp://localhost:8081/realms/masterตรวจ iss ใน token
authorization_endpoint…/protocol/openid-connect/authขั้น 2 — พาผู้ใช้ไปหน้า login
token_endpoint…/protocol/openid-connect/tokenขั้น 5 — แลก code เป็น token · และ refresh
jwks_uri…/protocol/openid-connect/certsดึง public key มา verify token
end_session_endpoint…/protocol/openid-connect/logoutlogout
userinfo_endpoint…/protocol/openid-connect/userinfoถามข้อมูลผู้ใช้เพิ่ม
introspection_endpoint…/protocol/openid-connect/token/introspectถาม Keycloak ตรง ๆ ว่า token ยังใช้ได้ไหม
grant_types_supported = ["authorization_code", "client_credentials", "implicit", "password",
  "refresh_token", "urn:ietf:params:oauth:grant-type:device_code",
  "urn:ietf:params:oauth:grant-type:jwt-bearer", "urn:ietf:params:oauth:grant-type:token-exchange",
  "urn:ietf:params:oauth:grant-type:uma-ticket", "urn:openid:params:grant-type:ciba"]
response_types_supported = ["code", "none", "id_token", "token", "id_token token",
  "code id_token", "code token", "code id_token token"]

issuer มี :8081 ติดมาด้วย เพราะ Keycloak เติม port จาก request · เอาไปตรวจ iss ต้องใส่ port ให้ตรงเป๊ะ ไม่งั้น validate ไม่ผ่านแบบหาสาเหตุยาก

components — ของสี่อย่างที่ต้องรู้จัก

Realm: wongnok พื้นที่แยกอิสระของแต่ละโปรเจกต์ — user/client/role ไม่ปนข้าม realm │ ├── Clients แอปที่ลงทะเบียนไว้ใช้ auth · มี client_id / client_secret · เช่น wongnok-api │ Client scope = ชุด claims ที่จะแนบไปกับ token │ ├── Users บัญชีผู้ใช้ในระบบ · login ด้วย username/email + password ที่ Keycloak │ ├── Groups รวม user เป็นกลุ่ม เช่น "admins", "chefs" · จัดสิทธิ์เป็นชุดได้ทีเดียว │ │ │ └───────> user ที่อยู่ใน group ได้ role ของ group นั้นอัตโนมัติ │ │ └── Roles <───────┘ สิทธิ์ เช่น "recipe:write" · ผูกกับ Group แทนผูกกับ User ทีละคน

Group → Role ทำงานจริง — พิสูจน์ด้วย token

== 3. map role recipe:write -> group chefs ==   HTTP 204
== 4. put user chef1 into group chefs ==        HTTP 204

== 5a. DIRECT realm roles of chef1 ==
['default-roles-wongnok']                       ← ไม่เคยผูก recipe:write กับ user เลย
== 5b. COMPOSITE/effective realm roles of chef1 ==
['default-roles-wongnok', 'offline_access', 'recipe:write', 'uma_authorization']
== 5c. groups of chef1 ==
['/chefs']

== access_token payload AFTER group->role ==
  "azp": "wongnok-api",
  "preferred_username": "chef1",
  "realm_access": { "roles": ["default-roles-wongnok", "recipe:write", "offline_access", "uma_authorization"] }

role โผล่ใน realm_access.roles (realm role) ไม่ใช่ resource_access — หาผิดที่แล้วจะงงว่าทำไมไม่เห็น

claim ที่ Keycloak ใส่มาใน access_token

{
  "exp": …, "iat": 1787368445,
  "jti": "onrtro:f1adacbf-bd71-072f-c8c8-b8a6d666cc03",
  "iss": "http://localhost:8081/realms/wongnok",
  "aud": "account",
  "sub": "5da7f595-196f-44a6-8dfd-64306ce734d6",
  "typ": "Bearer",
  "azp": "wongnok-api",
  "sid": "grEBoqUUX084tjH4AEFbI5aJ",
  "acr": "1",
  "allowed-origins": ["http://localhost:8080"],
  "realm_access": { "roles": ["default-roles-wongnok", "offline_access", "uma_authorization"] },
  "resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } },
  "scope": "openid profile email",
  "email_verified": true,
  "name": "Chef One",
  "preferred_username": "chef1",
  "given_name": "Chef", "family_name": "One",
  "email": "chef1@wongnok.dev"
}
⚠ aud = "account" ไม่ใช่ชื่อ client

ใครเขียนโค้ดตรวจว่า aud == "wongnok-api" จะ fail ทุกใบ · ตัวที่บอกว่า token ออกให้ client ไหนคือ azp ต่างหาก · อยากให้ aud เป็น client id ต้องไปเพิ่ม audience mapper เอง
ส่วน email / preferred_username จะมีก็ต่อเมื่อขอ scope profile email (default client scope ให้มาอยู่แล้ว)

อายุ token ค่าเริ่มต้นของ 26.7

expires_in         = 300     # access_token — 5 นาที
refresh_expires_in = 1800    # refresh_token — 30 นาที
token_type         = Bearer
scope              = openid profile email

access_token  len 1346   refresh_token len 694   id_token len 1037

ไม่ใช่ 1 ชั่วโมงตามที่หลายคนจำ · ถ้าอยากได้ token มาเทสต์เร็ว ๆ ต้องเปิด directAccessGrantsEnabled: true ที่ client ก่อนถึงจะใช้ password grant ได้

verify ด้วย public key อย่างเดียว — ไม่ต้องแชร์ secret

$ curl -s http://localhost:8081/realms/wongnok/protocol/openid-connect/certs
number of keys = 2
{'kid': 'wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbO...', 'kty': 'RSA', 'alg': 'RS256', 'use': 'sig', ...}
{'kid': 'Mf4wHVuGfb9hhl_aZu4jyayOm94BXmiUsDX413Re...', 'kty': 'RSA', 'alg': 'RSA-OAEP', 'use': 'enc', ...}

$ # Go stdlib ล้วน: ดึง JWKS → match kid → ประกอบ rsa.PublicKey → VerifyPKCS1v15
token header kid = wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4  alg = RS256
matched JWKS key: kid = wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4 use = sig alg = RS256
public key modulus bits = 2048  exponent = 65537
VERIFY OK  -> signature valid using ONLY the public key from jwks_uri (no client_secret used)
tampered token correctly REJECTED: crypto/rsa: verification error
⚠ jwks คืน 2 คีย์ ไม่ใช่ 1

ใบ use:sig alg:RS256 เอาไว้ verify · ใบ use:enc alg:RSA-OAEP เอาไว้เข้ารหัส · โค้ดที่หยิบ keys[0] ตรง ๆ จะถูกบ้างผิดบ้างแบบสุ่ม — ต้อง match ด้วย kid จาก header ของ JWT เสมอ

⚠ refresh_token verify ด้วย public key ไม่ได้
== JWT header of access_token ==
{ "alg": "RS256", "typ": "JWT", "kid": "wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4" }
== refresh_token header ==
{ "alg": "HS512", "typ": "JWT", "kid": "b2ffdb37-5e0e-4ae7-b420-370b9638d5b1" }

refresh_token เซ็นด้วย HS512 ซึ่งเป็น symmetric — secret อยู่ที่ Keycloak เท่านั้น · service อื่นเอา public key ไป verify มันไม่ได้และไม่ควรทำ ต้องส่งกลับให้ Keycloak แลกที่ token_endpoint เท่านั้น
ประโยค “token ทุกใบ verify ด้วย public key ได้” จริงเฉพาะ access_token กับ id_token

เลิกใช้แล้วเก็บกวาด

$ docker-compose down -v
 Container wongnok-keycloak Removed
 Volume kc_wongnok-keycloak-data Removed
 Network kc_default Removed

$ # image ยังคาไว้ ถ้าอยากลบ
$ docker rmi quay.io/keycloak/keycloak:26.7

12 · flow /login /callback /exchange /logout

endpointใครเป็นคนเรียกเรียกยังไงแตะ Redis ยังไงตอบอะไรกลับ
GET /api/v1/auth/login Browser window.location · ห้าม AJAX SET oauth_state:xxx TTL 300 วินาที 302 + Location: ไปหน้า login ของ Keycloak
GET /api/v1/auth/callback Browser (เดินตาม 302 ของ Keycloak) ไม่มีใครสั่ง — เบราว์เซอร์เดินเองตาม redirect GETDEL oauth_state:xxx แล้ว SET auth_ticket:yyy TTL 30 วินาที 302 + Location: ไป FRONTEND_URL/auth/success?ticket=yyy
(FRONTEND_URL = env ของ API เราเอง ไม่ใช่ของ Keycloak — URL ของหน้าเว็บฝั่ง frontend)
POST /api/v1/auth/exchange Frontend JS axios.post (AJAX) · body {ticket} GETDEL auth_ticket:yyy 200 + body {accessToken, refreshToken, bearerType, expiresAt}
POST /api/v1/auth/logout Frontend JS axios.post (AJAX) · body {refreshToken} ไม่แตะเลย 204 No Content (body ว่าง)
คำที่ต้องแยกให้ออกหมายความว่า
window.location = "…"สั่งเบราว์เซอร์ให้ย้ายทั้งหน้าไปที่ URL นั้น — หน้าปัจจุบันถูกทิ้ง แถบที่อยู่เปลี่ยน และ redirect ต่อ ๆ ไปเบราว์เซอร์เดินตามให้เอง
AJAX (axios.get(…))ยิง request เบื้องหลังโดยไม่เปลี่ยนหน้า — JavaScript ได้ response กลับมาเป็นข้อมูลก้อนหนึ่ง หน้าจอไม่ขยับ

ทำไม /callback ไม่คืน token ตรง ๆ แต่คืน ticket

เทียบสองสิ่งticket (อยู่ใน URL)accessToken (อยู่ใน body ของ POST)
เดินทางยังไงquery string ของ 302 → แถบที่อยู่response body ของ POST /exchange
เข้า access log ไหมเข้าไม่เข้า
เข้า browser history ไหมเข้าไม่เข้า
โผล่ใน Referer ไหมโผล่ (สำหรับ origin เดียวกัน)ไม่โผล่
อายุ30 วินาที300 วินาที (5 นาที) ตามค่า Keycloak
ใช้ได้กี่ครั้งครั้งเดียว — ถูก GETDEL ทันทีที่แลกใช้ซ้ำได้จนหมดอายุ
ถ้าหลุดแล้วเกิดอะไรผู้เก็บได้ต้องชิงใช้ให้ทันภายใน 30 วินาที และต้องชิงก่อนเจ้าของ — ซึ่งเจ้าของแลกไปแล้วตั้งแต่วินาทีแรกเข้าถึง API ในนามผู้ใช้ได้จนกว่าจะหมดอายุ

GETDEL คืออะไร — คำสั่ง Redis ที่ อ่านค่าและลบทิ้งในจังหวะเดียวกัน · สำคัญตรงที่มัน atomic: ถ้าสอง request มาพร้อมกัน มีแค่ตัวเดียวที่ได้ค่า อีกตัวได้ค่าว่าง — ทำให้ “ใช้ครั้งเดียว” เป็นจริงโดยไม่ต้องล็อกอะไรเอง

13 · goose + ตาราง users

DSN — ตัวที่ถูกต้อง กับตัวที่มีปัญหา

DSNgoose / pgx / lib/pqpsql (libpq)
postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok
แบบที่อยู่ในสไลด์
ผ่าน — ต่อเข้าฐาน wongnok ถูกต้อง พังสองต่อ — ดูข้างล่าง
postgres://postgres:Pe%40devp00l@localhost:5432/wongnok?sslmode=disable
แบบที่ถูก
ผ่าน ผ่าน
$ # ฝั่ง Go ไม่พังเลย — สาม parser ให้ผลตรงกันหมด
=== DSN: postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok
  net/url.Parse  -> user="postgres" password="Pe@devp00l" host="localhost:5432" rawquery="database=wongnok"
  pgx ParseConfig-> host="localhost" port=5432 user="postgres" password="Pe@devp00l" database="wongnok"
  lib/pq ParseURL-> "database='wongnok' host='localhost' password='Pe@devp00l' port='5432' user='postgres'"

$ goose up
2026/08/22 10:11:57 OK   20260822031131_create_users.sql (12.99ms)
2026/08/22 10:11:57 goose: successfully migrated database to version: 20260822031131

$ # ลงฐานไหน? เช็คทั้งสองฐาน
=== tables in DB 'postgres' ===   Did not find any relations.
=== tables in DB 'wongnok' ===
 public | goose_db_version | table | postgres
 public | users            | table | postgres
⚠ DSN เดียวกันนี้พังทันทีเมื่อเอาไป psql
$ psql "postgres://postgres:Pe@devp00l@localhost:5432/wongnok" -c "select current_database()"
psql: error: could not translate host name "devp00l@localhost" to address: Name does not resolve

$ psql "postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok" -c "select current_database()"
psql: error: invalid URI query parameter: "database"

① @ ที่ไม่ encode — Go ตัดที่ @ ตัวสุดท้าย (LastIndex) เลยได้ password ถูก แต่ libpq ตัดที่ @ ตัวแรก host เลยกลายเป็น devp00l@localhost → ต้องเขียน %40 เสมอ
② ?database= เป็นของฝั่ง Go เท่านั้น — pgx ใช้ชื่อคีย์ว่า database อยู่แล้วเลยตีความได้ แต่ psql ปฏิเสธตรง ๆ → ใช้ path /wongnok แทน
ทั้งสองข้อกัดตอน debug เพราะเราจะก็อป DSN เดิมไปเปิด psql ดูตาราง แล้วงงว่าทำไมต่อไม่ได้ ทั้งที่แอปรันได้

$ psql "postgres://postgres:Pe%40devp00l@localhost:5432/wongnok" -c "select current_database()"
 current_database
------------------
 wongnok
(1 row)

คำสั่ง goose ที่ใช้จริง

# .env — goose CLI อ่านไฟล์นี้ให้เอง (flag -env ค่าเริ่มต้นคือ .env)
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:Pe%40devp00l@localhost:5432/wongnok?sslmode=disable
GOOSE_MIGRATION_DIR=migrations
คำสั่งทำอะไรผลจริง
goose create create_users sqlสร้างไฟล์ migration เปล่าCreated new file: migrations/20260822031131_create_users.sql
goose upรัน migration ที่ยังไม่ได้รันOK … (8.73ms) + goose: successfully migrated database to version: …
goose downถอย 1 ขั้นOK 20260822031131_create_users.sql (5.22ms) — ไม่มีบรรทัด successfully
goose statusดูว่าอันไหนรันแล้ว/ยังPending หรือวันเวลาที่รัน
goose versionเลข version ปัจจุบันของฐานgoose: version 20260822031131
$ # ไฟล์ที่ goose create สร้างมาให้
-- +goose Up
SELECT 'up SQL query';

-- +goose Down
SELECT 'down SQL query';

goose CLI โหลด .env ใน cwd ให้เอง — ไม่ต้อง source .env ไม่ต้อง export · พิสูจน์โดย env -u GOOSE_DRIVER -u GOOSE_DBSTRING -u GOOSE_MIGRATION_DIR goose status แล้วยังรันได้ พอย้าย .env ออกถึงพิมพ์ usage · แต่ต้องรันในโฟลเดอร์ที่มี .env

migration ของตาราง users

-- +goose Up
CREATE TABLE users (
    id                 UUID PRIMARY KEY,
    email              VARCHAR(255) NOT NULL,
    name               VARCHAR(255),
    bio                TEXT,
    uid                VARCHAR(255) NOT NULL,
    image_url          TEXT,
    preferred_username VARCHAR(255),
    last_signed_in_at  TIMESTAMPTZ,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at         TIMESTAMPTZ
);

CREATE INDEX        idx_users_email      ON users (email);
CREATE UNIQUE INDEX idx_users_uid        ON users (uid);
CREATE INDEX        idx_users_deleted_at ON users (deleted_at);

-- +goose Down
DROP TABLE users;
$ psql -U postgres -d wongnok -c '\d users'
                              Table "public.users"
     Column       |           Type           | Collation | Nullable | Default
--------------------+--------------------------+-----------+----------+---------
 id                 | uuid                     |           | not null |
 email              | character varying(255)   |           | not null |
 name               | character varying(255)   |           |          |
 bio                | text                     |           |          |
 uid                | character varying(255)   |           | not null |
 image_url          | text                     |           |          |
 preferred_username | character varying(255)   |           |          |
 last_signed_in_at  | timestamp with time zone |           |          |
 created_at         | timestamp with time zone |           | not null | now()
 updated_at         | timestamp with time zone |           | not null | now()
 deleted_at         | timestamp with time zone |           |          |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "idx_users_deleted_at" btree (deleted_at)
    "idx_users_email" btree (email)
    "idx_users_uid" UNIQUE, btree (uid)
คอลัมน์มาจากไหนทำไมต้องมี
uidsub ใน token ของ Keycloakกุญแจที่ผูกผู้ใช้ในฐานเรากับผู้ใช้ใน Keycloak — จึงต้อง unique
preferred_usernameclaim ชื่อเดียวกันใน tokenชื่อที่เอาไว้แสดง
image_urlไม่มีใน claim — ผู้ใช้ใส่เองสไลด์ฉบับ Post เพิ่งเพิ่มมา เพื่อรองรับ PUT /api/v1/users/me ในโจทย์สอบ
email · nameclaim email / nameข้อมูลโปรไฟล์ที่ Keycloak ให้มา
last_signed_in_atเราเขียนเองตอน callbackรู้ว่าใครยังใช้งานอยู่
deleted_atGORM soft deleteมี index เพราะทุก query จะมี WHERE deleted_at IS NULL

soft delete ของ GORM ทำงานยังไง

type User struct {
	ID        uuid.UUID      `gorm:"type:uuid;primaryKey"`
	DeletedAt gorm.DeletedAt `gorm:"index:idx_users_deleted_at"`
	// ชนิดต้องเป็น gorm.DeletedAt เท่านั้น — *time.Time ใช้ไม่ได้
}
1) Create OK, id = bae1dc16-8585-4f91-bbe6-d30160e7dbdd
2) db.Delete() -> DeletedAt.Valid = true
3) Count() ปกติ (ถูกกรอง deleted_at IS NULL) = 0
4) Unscoped().Count() (นับแถวที่ soft delete ด้วย) = 1
⚠ unique index บน uid เฉย ๆ ชนกับ soft delete
INSERT 0 1
UPDATE 1                       # soft delete
     uid      | soft_deleted
--------------+--------------
 oidc-sub-123 | t
INSERT ...
ERROR:  duplicate key value violates unique constraint "idx_users_uid"
DETAIL:  Key (uid)=(oidc-sub-123) already exists.

soft delete แล้วแถวยังอยู่ในตาราง unique index จึงยังกันอยู่ — ผู้ใช้ที่ลบบัญชีไปแล้ว login กลับเข้ามาไม่ได้อีกเลย

5) สมัครใหม่ uid เดิม -> err = ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505)
6) FirstOrCreate      -> err = ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505)

FirstOrCreate เจ็บกว่า — First หาไม่เจอ (โดน scope deleted_at IS NULL กรอง) เลยไป Create แล้วชน unique ของแถวที่ถูกลบ · โค้ด OIDC ชอบใช้ท่านี้พอดี

ทางแก้ — partial unique index
DROP INDEX idx_users_uid;
CREATE UNIQUE INDEX idx_users_uid ON users (uid) WHERE deleted_at IS NULL;
DROP INDEX
CREATE INDEX
INSERT 0 1
     uid      | active
--------------+--------
 oidc-sub-123 | f
 oidc-sub-123 | t
(2 rows)
ERROR:  duplicate key value violates unique constraint "idx_users_uid"   # ยังกัน uid ซ้ำในแถวที่ active

ได้ทั้งสองอย่าง: สมัครใหม่หลัง soft delete ผ่าน แต่ยังกัน uid ซ้ำในแถวที่ยัง active · ฝั่ง GORM เขียนเป็น gorm:"uniqueIndex:idx_users_uid,where:deleted_at IS NULL"

⚠ id UUID PRIMARY KEY ไม่มี DEFAULT
รอบแรก:
Create โดยไม่เซ็ต ID -> err=<nil>, id=00000000-0000-0000-0000-000000000000

รอบสอง (uid ต่างกัน):
Create โดยไม่เซ็ต ID -> err=ERROR: duplicate key value violates unique constraint "users_pkey" (SQLSTATE 23505)

แถวในตาราง:
                  id                  |    uid
--------------------------------------+-----------
 00000000-0000-0000-0000-000000000000 | no-id-set

บั๊กเงียบสุดคลาสสิก — user คนแรกเข้าได้ด้วย UUID ศูนย์ คนที่สองถึงจะพัง · แก้ได้สองทาง: เติม DEFAULT gen_random_uuid() ใน migration หรือเขียน BeforeCreate hook ใน GORM

อ่าน goose status ให้ถูก

$ goose status                              # หลัง up
    Applied At                  Migration
    =======================================
    Sat Aug 22 03:11:57 2026 -- 20260822031131_create_users.sql

$ goose down ; goose status
    Applied At                  Migration
    =======================================
    Pending                  -- 20260822031131_create_users.sql

หัวตาราง Applied At / Migration กับเส้น ==== goose พิมพ์มาให้ทุกครั้งที่เรียก status ไม่ว่าจะ pending หรือ applied · และ goose ใส่ prefix วันเวลา (2026/08/22 11:48:24 ) นำหน้า ทุกบรรทัดที่มันพิมพ์ รวมถึง create · status · version เหมือนบล็อก goose up ข้างบน — บล็อก status นี้กับตารางคำสั่งข้างบนตัด prefix ออกให้พอดีความกว้าง ของจริงบนเครื่องจะมีติดมาด้วยเสมอ

$ psql -c 'select * from goose_db_version;' -c 'show timezone;' ; date
 id |   version_id   | is_applied |           tstamp
----+----------------+------------+----------------------------
  1 |              0 | t          | 2026-08-22 03:11:46.240423
  3 | 20260822031131 | t          | 2026-08-22 03:14:26.155434
(2 rows)

 TimeZone
----------
 UTC

local now:
Sat Aug 22 10:15:38 +07 2026

สองเรื่องที่ทำให้คนงง:
① เวลาเป็น UTC — tstamp เป็น timestamp ไม่มี timezone และเก็บ UTC · เพิ่งรันเสร็จแต่โชว์ย้อนหลัง 7 ชั่วโมง เป็นเรื่องปกติ · ชื่อไฟล์ที่ goose create ตั้งให้ก็เป็น UTC เหมือนกัน (20260822031131 = 03:11:31 UTC ไม่ใช่ 10:11 เวลาไทย)
② id กระโดด 1 → 3 — เพราะ goose down ลบแถวทิ้งจริง ไม่ได้แค่ mark is_applied=false

14 · ตารางกับดักรวมทั้งวัน

เขียนแบบนี้สิ่งที่เกิดขึ้นจริงแก้
`env:"X,required" envDefault:"y"`required ไม่มีผลเลย — ได้ default เงียบ ๆ err=<nil>เลือกอย่างใดอย่างหนึ่ง อย่าใส่คู่กัน
`env:"X,notEmpty" envDefault:"y"`โดนกลบเหมือนกันเป๊ะ — ทั้งกรณีไม่ตั้ง env และตั้งเป็นค่าว่างเอา envDefault ออก
POSTGRES_DSN= ใน compose (ไม่ใส่ค่า),required ผ่านฉลุย — มันเช็คแค่ว่า “มีตัวแปรนี้ไหม”ใช้ ,notEmpty (และห้ามมี envDefault)
`env:"SECRET,file"` ชี้ไฟล์ที่ลงท้ายด้วย newlineได้ค่าติด \n มาด้วย → รหัสผ่านผิดแบบ debug ไม่เจอstrings.TrimSpace หลัง parse
APP_SHUTDOWN_TIMEOUT=10unable to parse duration: time: missing unit in duration "10"ใส่หน่วยเสมอ — 10s
ใช้ cfg ต่อโดยไม่เช็ค err จาก ParseAsfield ที่พังได้ zero value แต่ field อื่นเติมครบ → ได้ Port=0 ไปใช้เช็ค err แล้ว os.Exit(1) ทันที
เขียนลง map ที่มาจาก env โดยไม่ตั้งค่าได้ nil map → panic: assignment to entry in nil mapใส่ envDefault หรือ make เอง
import _ ".../godotenv/autoload" แล้ว .env พิมพ์ผิดเงียบสนิท — ไม่ panic ไม่ log exit 0 แต่ไม่มี env จาก .env เลยสักตัวใช้ godotenv.Load() แล้วจับ err เอง
.env มีบรรทัดพัง 1 บรรทัดall-or-nothing — บรรทัดก่อนหน้าที่ถูกต้องก็ไม่ถูก set เลย ทั้งไฟล์ถูกทิ้งอ่าน error ของ Load() ทุกครั้ง
123INVALID=x หรือ =novalue ใน .enverr=<nil> — godotenv ไม่ validate ชื่อ key เลยตรวจ .env.example ให้ตรงกับที่โค้ดอ่านจริง
P1="a$BCD" ใน .envquote คู่ทำ variable expansion — ได้ "aSURPRISE" · จับเฉพาะชื่อ [A-Z0-9_]+ (PASS="a$bcd" พิมพ์เล็ก รอด)secret ที่มี $ ใช้ quote เดี่ยว
TOKEN=ab #cd ใน .envได้ "ab" — comment ท้ายบรรทัดถูกตัด · ต้องมีช่องว่างนำหน้า # เท่านั้น (TOKEN=ab#cd ติดกันได้ "ab#cd" ครบ)ใส่ quote ครอบ
TAB="a\tb" ใน .envได้ "atb" — รองรับแค่ \n กับ \rอย่าพึ่ง escape อื่นใน .env
รัน binary จากโฟลเดอร์อื่น.env หาไม่เจอ — มันหาจาก cwd ไม่ใช่ที่ตั้ง binaryรันจาก project root หรือส่ง env ตรง ๆ
validate:"oneof=dev,staging,prod"panic ตอน Struct() ครั้งแรก ไม่ใช่ตอน New() — โปรแกรม start ผ่านแล้วตายตอน request แรกคั่นด้วยเว้นวรรค: oneof=dev staging prod
validate:"gte=1" บน time.Duration= 1 นาโนวินาที · 500ms ผ่านฉลุยเขียนหน่วยเสมอ — gte=1s
validate:"required" บน time.Durationกันได้แค่ 0s · -5s ผ่านทั้ง ParseAs และ validatorvalidate:"required,gt=0"
DB_HOST=db_server + validate:"hostname|ip"ตก — hostname ไม่ยอมรับ underscore (และไม่ยอมรับ host:port)ตั้งชื่อ service ด้วย - หรือเปลี่ยน tag
validator.New() ใหม่ทุก requestช้ากว่า singleton ~72 เท่า (8638 ns vs 120.6 ns · 224 allocs vs 1)ประกาศเป็น package-level var ครั้งเดียว
validator.New() เปล่า ๆ + required บน nested structtag นั้นถูกเมิน zero struct ผ่านvalidator.New(validator.WithRequiredStructEnabled())
FieldError.Value().(int64) ของ Durationpanic — interface conversion: interface {} is time.Duration, not int64 · และถ้าพิมพ์ด้วย %d/%#v จะได้ 100000000 แทน 100msassert เป็น time.Duration · พิมพ์ด้วย %v
Load() คืน *Configตอน error คืน nil — ลืมเช็ค err แล้ว panic · แถม handler แก้ค่าที่ validate แล้วได้คืนเป็น value Config
claims["sub"].(int) กับ jwt.MapClaimspanic — MapClaims decode ตัวเลขเป็น float64 เสมอ.(float64) หรือใช้ custom claims struct
เซ็น JWT โดยไม่ใส่ expjwt.Parse ผ่าน (err=<nil>) = token ตลอดชีพใส่ exp ตอนเซ็น + jwt.WithExpirationRequired() ตอนตรวจ
jwt.Parse โดยไม่ใส่ WithValidMethodsกัน algorithm confusion ได้เพราะ type ไม่ match โดยบังเอิญ เท่านั้นใส่ jwt.WithValidMethods([]string{"RS256"}) เสมอ
if ok { ให้ผ่าน } หลัง r.BasicAuth()ช่องโหว่ — ok=true แม้รหัสผิด (แปลว่า “แกะ header สำเร็จ”)เทียบ user/pass เองด้วย subtle.ConstantTimeCompare
ยัด roles + permissions + ชื่อไทยลง JWTบวมเป็น 553 byte = 17.3 เท่าของ session id — และเดินทางทุก requestใส่แค่ sub · exp · role ที่จำเป็น
ตรวจ aud == "wongnok-api" กับ token ของ Keycloakfail ทุกใบ — aud เป็น "account"ดู azp แทน หรือเพิ่ม audience mapper
หยิบ keys[0] จาก jwks_uriถูกบ้างผิดบ้าง — มี 2 คีย์ (use:sig RS256 กับ use:enc RSA-OAEP)match ด้วย kid จาก header ของ JWT
เอา public key ไป verify refresh_tokenไม่ได้ — มันเซ็นด้วย HS512 (symmetric)ส่งกลับ Keycloak ที่ token_endpoint
ลอก compose ของ Keycloak มาแค่ services:error volume undefined ตั้งแต่ยังไม่ทันขึ้นใส่ top-level volumes: ด้วย
เชื่อว่า --import-realm import อะไรให้ไม่ทำอะไรเลยถ้าไม่มีไฟล์ใน /opt/keycloak/data/import — และไม่ errormount ไฟล์ realm json เข้าไปเอง
KC_HOSTNAME: localhost ค้างไว้ แล้วเรียก Keycloak ด้วยชื่อ service ใน composeoidc.NewProvider() error ตั้งแต่บูต — issuer URL provided to client … did not match the issuer URL returned by providerลบ KC_HOSTNAME ทิ้ง (ฉบับ Post ทำแล้ว)
ปล่อย issuer ลอยตาม Host: บน productionยิง Host: evil.example.com แล้วได้ issuer = http://evil.example.com/realms/masterปักหมุด KC_HOSTNAME ให้เป็นโดเมนจริง — อย่าปิด strict
DSN ที่มี @ ไม่ encodeGo ผ่าน แต่ psql พัง — could not translate host name "devp00l@localhost"เขียน %40
DSN ที่ใช้ ?database=wongnokGo ผ่าน แต่ psql ปฏิเสธ — invalid URI query parameterใช้ path /wongnok
unique index บน uid เฉย ๆ + soft deleteผู้ใช้ที่ลบบัญชี login กลับมาไม่ได้อีก · FirstOrCreate ก็พังpartial index … WHERE deleted_at IS NULL
id UUID PRIMARY KEY ไม่มี DEFAULT + ลืมเซ็ตใน GORMคนแรกได้ UUID ศูนย์เงียบ ๆ คนที่สองถึงพังDEFAULT gen_random_uuid() หรือ BeforeCreate
อ่าน Applied At ของ goose เป็นเวลาไทยเป็น UTC — ต่างจากเวลาไทย 7 ชั่วโมงบวก 7 เอง หรือดูที่ version_id แทน
คาดว่า exit status 1 เป็นข้อความของโปรแกรมgo run พิมพ์เอง — build เป็น binary แล้วรันจะไม่มีบรรทัดนี้—

15 · ตารางแปล error / panic ที่เจอบ่อยของวันนี้

ข้อความแปลว่า
env: required environment variable "MYVAR2" is not set,required ไม่เจอ env ตัวนั้น · type จริงคือ env.VarIsNotSetError ห่อใน env.AggregateError
env: environment variable "NE_VAR" should not be empty,notEmpty เจอค่าว่าง · type คือ env.EmptyVarError
env: could not load content of file "…" from variable F_VAR: open …: no such file or directory,file ชี้ไป path ที่ไม่มีจริง · type คือ env.LoadFileContentError
env: parse error on field "AppPort" of type "int": strconv.ParseInt: parsing "abc": invalid syntaxค่าใน env แปลงเป็น type ของ field ไม่ได้ · field อื่นยังถูกเติมครบ
env: parse error on field "ShutdownTimeout" of type "time.Duration": unable to parse duration: time: missing unit in duration "10"ลืมหน่วยของเวลา — เขียน 10s
env: parse error on field "Lvl" of type "main.LogLevel": unknown log level "VERBOSE"UnmarshalText ที่เราเขียนเองคืน error — ข้อความหลัง : คือของเราเอง
error หลายอันคั่นด้วย "; "env.AggregateError — รวมทุกปัญหาไว้ในรอบเดียว ไม่ได้หยุดที่ตัวแรก
open .env: no such file or directoryไม่มีไฟล์ .env ใน cwd · type *fs.PathError · errors.Is(err, os.ErrNotExist) = true
unexpected character "\n" in variable name near "this line has no equals sign\nAFTER=2\n".env มีบรรทัดที่ไม่มี = · ทั้งไฟล์ถูกทิ้ง
unterminated quoted value "oops.env มี quote ที่ไม่ปิด · ทั้งไฟล์ถูกทิ้งเหมือนกัน
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tagvalidator: App.Port เกินค่าที่ lte กำหนด · รูปแบบนี้เป๊ะทุกช่องว่าง
panic: Undefined validation function 'staging' on field 'Env'ใช้ comma ใน oneof · panic ตอน validate.Struct() ครั้งแรก ไม่ใช่ตอน New()
token has invalid claims: token is expiredJWT: exp ผ่านไปแล้ว · errors.Is(err, jwt.ErrTokenExpired) = true
token has invalid claims: token is not valid yetJWT: nbf อยู่ในอนาคต
token has invalid claims: token has invalid audienceJWT: aud ไม่ตรงกับที่ WithAudience กำหนด
token has invalid claims: token is missing required claim: exp claim is requiredใส่ WithExpirationRequired() แต่ token ไม่มี exp
token has invalid claims: token is missing required claim: iss claim is requiredWithIssuer() ทำให้ iss กลายเป็น required ไปด้วย — ไม่ใช่ “issuer ผิด”
token signature is invalid: signature is invalidJWT ถูกแก้ หรือ secret ไม่ตรง · ErrSignatureInvalid และ ErrTokenSignatureInvalid เป็น true ทั้งคู่
token signature is invalid: signing method HS256 is invalidalg ของ token ไม่อยู่ในรายการของ WithValidMethods · ErrSignatureInvalid เป็น false ในเคสนี้
token signature is invalid: token is unverifiable: 'none' signature type is not allowedเจอ token alg=none — golang-jwt/v5 บล็อกให้ตั้งแต่ค่าเริ่มต้น
token signature is invalid: key is of invalid type: HMAC verify expects []bytealgorithm confusion — keyfunc คืน *rsa.PublicKey แต่ token อ้างว่าเป็น HS256
token is malformed: token contains an invalid number of segmentstoken ว่าง / เป็นข้อความมั่ว / มีไม่ครบ 3 ส่วน — ทั้งสามเคสได้ข้อความเดียวกัน
oidc: malformed jwt: go-jose/go-jose: compact JWS format must have three partsเหมือนข้างบน แต่มาจากฝั่ง go-oidc ซึ่งใช้ go-jose/v4
psql: error: could not translate host name "devp00l@localhost" to address: Name does not resolveDSN มี @ ในรหัสผ่านที่ไม่ได้ encode — libpq ตัดที่ @ ตัวแรก
psql: error: invalid URI query parameter: "database"DSN ใช้ ?database= ซึ่งเป็นของฝั่ง Go เท่านั้น — ใช้ path /wongnok
ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505)สมัครซ้ำด้วย uid เดิมหลัง soft delete — แถวเก่ายังอยู่ในตาราง
ERROR: duplicate key value violates unique constraint "users_pkey" (SQLSTATE 23505)ลืมเซ็ต ID ใน GORM — คนที่สองชนกับ UUID ศูนย์ของคนแรก
runtime error: invalid memory address or nil pointer dereferenceLoad() คืน *Config เป็น nil ตอน error แล้วมีคนอ่านต่อโดยไม่เช็ค err
docker: unknown command: docker composesymlink ของ compose plugin ค้างไปที่ Docker Desktop ที่ถูกลบแล้ว — ใช้ docker-compose (มีขีด)
docker: error getting credentials - err: exec: "docker-credential-osxkeychain": executable file not found in $PATHซาก credsStore ใน ~/.docker/config.json
service "keycloak" refers to undefined volume wongnok-keycloak-data: invalid compose projectลอก compose มาแค่ services: ลืม top-level volumes:

💬 ถามได้เจอ error ที่ไม่มีในตารางนี้ ส่งข้อความเต็ม ๆ มาได้เลย เดี๋ยวรันซ้ำแล้วเพิ่มลงตาราง — ทุกบรรทัดในตารางนี้มาจากของที่รันเจอจริงทั้งหมด ไม่ได้ลอกมาจากที่ไหน

อ้างอิง