ปัญหา
caller ไม่ได้เป็นคนเสมอไป ตัว service ที่เรียก service อื่น, billing job ที่รันทุกคืน, queue worker หรือ deployment pipeline ไม่มีผู้ใช้ให้พาไปผ่านหน้า sign-in แต่ API ที่ปลายทางก็ยังต้องรู้ว่าใครเป็นคนเรียก และ caller ตัวนั้นทำอะไรได้บ้าง
ทางลัดที่ใช้กันบ่อยมักเสื่อมตามเวลา static API key ไม่เคยหมดอายุ วิ่งไปกับทุก request และสุดท้ายก็ถูกก็อปไปไว้หลายที่จนไม่มีใครจำได้ "service user" ที่มี password แล้วถูกยัดเข้า flow ที่ออกแบบมาให้คนใช้ ก็จะติด MFA และบังไว้ว่าระบบไหนเป็นคนเรียกจริง ๆ ส่วนการเชื่อทุกอย่างที่มาจากใน network ก็ใช้ได้แค่จนกว่าจะมี workload สักตัวโดนเจาะ สิ่งที่ caller ต้องมีคือ identity ของตัวเอง วิธีพิสูจน์ identity นั้น และ token อายุสั้นที่ scope แคบ ที่ API ตรวจได้เหมือน access token ตัวอื่น ๆ
ทำงานยังไง
client credentials grant (RFC 6749 §4.4) เป็น flow ที่สั้นที่สุดของ OAuth 2.0: request ไปที่ token endpoint หนึ่งครั้งและ response กลับมาหนึ่งครั้ง ไม่มี authorization endpoint ไม่มี redirect และไม่มี authorization code เพราะไม่มีใครถูกขอ consent ตอน request: client ทำในนามตัวเอง หรือภายใต้ข้อตกลงที่ทำไว้ล่วงหน้ากับ authorization server การ authenticate client คือการ authorize ทั้งหมดแล้ว
- Token request client ส่ง
POSTไปที่ token endpoint พร้อม body แบบ form-encoded:grant_type=client_credentialsและscopeถ้าต้องการ มัน authenticate ใน request เดียวกันนี้เลย - Token response authorization server authenticate client ตัดสินว่า client ตัวนี้ควรได้อะไร แล้วคืน
access_token,token_typeและปกติก็มีexpires_inมาด้วย - API call client ส่ง
Authorization: Bearer <token>ไป แล้ว API ก็ validate token (ดู JWT Validation) และ authorize request จาก claim ของมัน - ใช้ซ้ำ แล้วค่อยต่ออายุ client เก็บ token ไว้ใน memory ส่งไปกับทุก call และทำขั้นที่ 1 ซ้ำก่อน token จะหมดอายุไม่นาน
client คือใคร client ถูก register ไว้กับ authorization server ล่วงหน้า: มี client_id, วิธี authenticate และ scope หรือ API ที่มันขอได้ ตัว grant นี้มีไว้ให้ confidential client เท่านั้น (RFC 6749 §4.4 และใน draft ของ OAuth 2.1 ก็ไม่เปลี่ยน): ซอฟต์แวร์ที่รันอยู่ในที่ที่เก็บ credential ให้พ้นมือผู้ใช้ได้ เช่น server, job หรือ daemon ส่วน single-page app หรือ mobile app เก็บ credential ไม่ได้ เลยใช้ grant นี้ไม่ได้
มันพิสูจน์ตัวเองยังไง วิธีข้างล่างมาจาก registry OAuth Token Endpoint Authentication Methods ของ IANA (ที่มี none สำหรับ public client อยู่ด้วย) บวก workload identity federation เข้าไป เรียงคร่าว ๆ จากอ่อนสุดไปแข็งสุด:
| วิธี | client ถืออะไรไว้ | อะไรถูกส่งไปที่ token endpoint |
|---|---|---|
client_secret_basic, client_secret_post (RFC 6749 §2.3.1) |
shared secret | ตัว secret เอง ใน HTTP Basic header หรือใน form body |
client_secret_jwt (OpenID Connect Core §9) |
shared secret | JWT อายุสั้นที่ป้องกันด้วย HMAC ที่ใช้ secret เป็น key: secret ไม่ต้องวิ่งบนสาย แต่ทั้งสองฝั่งก็ยังรู้ secret อยู่ดี |
private_key_jwt (RFC 7523, OpenID Connect Core §9) |
private key | JWT อายุสั้นที่ sign ด้วย key นั้น ใส่ไว้ใน client_assertion ฝั่ง server ถือแค่ public key |
tls_client_auth, self_signed_tls_client_auth (RFC 8705) |
private key กับ X.509 certificate | ไม่มีอะไรนอกจาก client_id: TLS handshake พิสูจน์ว่าถือ key อยู่จริง แล้ว server ก็จับคู่ certificate กับ client |
| Workload identity federation | ไม่มีอะไรที่อายุยาว | token อายุสั้นที่ platform ออกให้ workload โดยส่งไปเป็น client_assertion หรือเป็น subject token ของ token exchange |
RFC 9700 ที่เป็น OAuth security best current practice แนะนำวิธีแบบ asymmetric คือ mutual TLS หรือ private_key_jwt: authorization server จะได้ไม่มี symmetric secret ให้ต้องเก็บ ทำให้ของที่รั่วจากฝั่ง server เอาไปปลอมเป็น client ไม่ได้
token บอกอะไร พอไม่มีผู้ใช้มาเกี่ยว ตัว token ก็เลยพูดถึง client โดยใน JWT access token profile (RFC 9068) ค่า sub ควรเป็น identifier ที่ authorization server ใช้เรียก client application และมี client_id อยู่เสมอ ส่วน aud บอกชื่อ API และ scope บอกว่า client ทำอะไรที่นั่นได้บ้าง
ทำไมไม่มี refresh token refresh token ให้ client ขอ access token ใหม่ได้โดยไม่ต้องพาผู้ใช้ไปเดิน flow ใหม่ แต่ตรงนี้ไม่มีใครต้องถูกพาไปไหน: client authenticate ใหม่ได้ทุกเมื่อที่ต้องการ ตัว refresh token เลยเป็นแค่ secret อายุยาวอีกตัวที่ต้องเก็บ RFC 6749 §4.4.3 เขียนไว้เป็น "SHOULD NOT" และไม่ได้บอกเหตุผล แล้ว server ในทางปฏิบัติก็ทำตาม: Microsoft Entra ID ไม่เคยออก refresh token ใน flow นี้ ส่วน Keycloak ก็ไม่ออกให้ ยกเว้นจะเปิด switch ที่ deprecated แล้ว
ใช้ตอนไหนดี
- Service-to-service call ที่ caller ทำในอำนาจของตัวเอง: Orders อ่าน stock level หรือ reporting service ดึงข้อมูลจาก billing API
- Scheduled job, batch run, queue worker และ daemon: อะไรก็ตามที่รันเองโดยไม่มีคนเฝ้า
- Automation: CI/CD pipeline, operator และ infrastructure tooling ที่เรียก management API
- ไม่ใช่สำหรับอะไรที่ทำแทนผู้ใช้ ถ้ามีคนอยู่เบื้องหลัง request ให้ใช้ user flow อย่าง OAuth 2.0 Authorization Code + PKCE เพื่อให้ token มี identity และ consent ของผู้ใช้คนนั้น ส่วน service ที่อยู่กลาง request ของผู้ใช้และต้องเรียก downstream API ในนามผู้ใช้คนนั้น ควรแลก token ที่ได้รับมา (OAuth 2.0 Token Exchange, RFC 8693 หรือ pattern on-behalf-of) แทนที่จะเปลี่ยนไปใช้ client credentials ของตัวเอง ไม่อย่างนั้นผู้ใช้จะหายไปจาก audit trail และ permission กว้าง ๆ ของ service ก็จะมาแทนที่สิ่งที่ผู้ใช้ได้รับอนุญาตจริง
- ไม่ใช่สำหรับ public client browser, mobile app และ desktop app เก็บ client credential ไม่ได้
ได้อะไร เสียอะไร
- client credential คือสิ่งที่ต้องปกป้อง ใครถือมันก็ขอ token ในนาม service ได้ ตอนไหนก็ได้ จากที่ไหนก็ได้ shared secret เป็นแบบที่อ่อนที่สุด: มันอยู่ใน configuration, pipeline และบ่อยเกินไปก็อยู่ใน source control มันถูกส่งไปทั้งก้อนทุกครั้งที่ขอ token และตัวที่ก็อปไปก็ใช้ได้ดีเท่าตัวจริง วิธีแบบ asymmetric เก็บ private key ไว้ฝั่ง client ส่วน workload identity ไม่เหลือ credential ที่ต้องเก็บไว้เลย
- ไม่มีผู้ใช้ก็ไม่มีขอบเขตแบบรายผู้ใช้ token จาก grant นี้มี permission ระดับ application:
inventory:writeแปลว่า inventory ทั้งหมด ไม่ใช่ของลูกค้าคนเดียว least privilege เลยต้องมาจากตอน register: หนึ่ง client ต่อหนึ่ง workload, scope ที่แคบที่สุดที่ยังใช้งานได้ และ token ที่จำกัดไว้ audience เดียว (RFC 9700 §2.3) - bearer token ใช้ได้กับใครก็ตามที่ถือมัน token ที่รั่วจาก log หรือจาก hop ที่โดนเจาะเอาไป replay ได้จนกว่าจะหมดอายุ ให้ตั้งอายุสั้น ๆ และสำหรับ API ที่มีมูลค่าสูง ให้ผูก token ไว้กับ key ที่มีแค่ client ถือ: certificate-bound token ผ่าน mutual TLS (RFC 8705,
cnf.x5t#S256) หรือ DPoP (RFC 9449,cnf.jkt) ตัว RFC 9700 แนะนำ sender-constrained access token - revoke ได้แบบหยาบ ๆ ไม่มี session ให้ปิด การ disable client หรือ rotate credential ของมันหยุดการออก token ใหม่ได้ แต่ token ที่ออกไปแล้วยังผ่าน local validation ไปจนถึง
exp - token endpoint กลายเป็น dependency token ที่ cache ไว้พาผ่านช่วงที่ authorization server ล่มสั้น ๆ ได้ แต่ client ที่ขอ token ทุกครั้งที่เรียกจะทำให้ทุกอาการสะดุดและทุก rate limit กลายเป็น request ที่ fail
- client กับผู้ใช้สับสนกันได้ ถ้า client ID กับ user ID ใช้ namespace เดียวกัน API ก็อาจเข้าใจ
subของ client ว่าเป็นของผู้ใช้ ตัว RFC 9700 §4.15 บอก authorization server ว่าในกรณีนั้นอย่าให้ client เลือกclient_idเอง หรือไม่ก็ให้ API มีวิธีอื่นแยก token สองแบบนี้ออกจากกัน - workload identity ย้ายความไว้ใจไปที่อื่น ไม่ได้ลบมันทิ้ง ตอนนี้ authorization server ไว้ใจ token issuer ของ platform และ subject string อย่าง Kubernetes service account หรือ repository กับ branch อะไรก็ตามที่รันภายใต้ subject นั้นได้ก็จะได้ token เพราะฉะนั้น trust rule ต้องปักหมุด issuer, subject และ audience ไว้ให้ตรงเป๊ะ
ข้อควรรู้ตอนลงมือทำ
- Request body เป็นแบบ form-encoded (
application/x-www-form-urlencoded) ไม่ใช่ JSON มันวิ่งผ่าน TLS โดยไม่มี credential อยู่ใน URL เด็ดขาด และใช้วิธี authenticate แค่วิธีเดียวต่อ request ถ้าใช้ HTTP Basic ตัว RFC 6749 §2.3.1 ให้ client form-urlencode ID กับ secret ก่อนเอามาต่อกันแล้ว encode เป็น Base64 ขั้นนี้ implementation ข้ามกันบ่อยจน draft ของ OAuth 2.1 (draft 16 เดือนกันยายน 2026 ที่ยังเป็นเอกสารของ working group) กลับ default: server ต้องรับ credential ใน form body และจะรับ Basic ด้วยก็ได้ เช็กว่า server ของเราคาดหวังแบบไหน - Error client authentication ที่ fail คือ
invalid_client(HTTP 401 ถ้า client ใช้ headerAuthorization) ถ้าขอ scope ที่ client ไม่มีสิทธิ์จะได้invalid_scopeและ client ที่ไม่ได้รับอนุญาตให้ใช้ grant นี้จะได้unauthorized_client(RFC 6749 §5.2) ทั้งสามตัวเป็นปัญหา configuration เพราะฉะนั้นให้ตั้ง alert แทนที่จะ retry - Cache token ใช้ authorization server, client, audience และ scope เป็น key ของ cache ใช้ token ซ้ำไปจนก่อน
expires_inจะหมดไม่นาน โดยเผื่อ margin ไว้ครอบ clock skew และ request ที่ยังวิ่งอยู่ ทำให้การต่ออายุเป็นแบบ single-flight การเรียกที่มาเป็นชุดจะได้ทำให้เกิด token request แค่ครั้งเดียว ไม่ใช่ร้อยครั้ง และถ้า API ตอบ 401 กับ token ที่ cache ไว้ ให้ทิ้งมันแล้วขอตัวใหม่หนึ่งครั้ง OAuth client library ส่วนใหญ่ทำเรื่องนี้ให้: อย่างAcquireTokenForClientของ MSAL ก็เช็ก application token cache ก่อนเรียก token endpoint ถ้ามีหลาย replica ให้แชร์ cache หรือกระจายจังหวะการต่ออายุออกไป - อายุและข้อจำกัด อายุที่สั้นลงทำให้ช่วงที่ replay ได้แคบลง แต่ก็เพิ่มอัตรา token request ส่วนอายุหนึ่งชั่วโมงเป็น default ที่ใช้กันบ่อย (Microsoft Entra ID ให้ token แต่ละตัวมีอายุ default 60 ถึง 90 นาที) ตัว token endpoint มี rate limit และบางที่ก็คิดเงินตามการใช้: Entra ID throttle client ที่ขอถี่เกินไป ส่วน Amazon Cognito คิดเงินการใช้แบบ machine-to-machine ตามจำนวน app client ที่ active และจำนวน token request และแนะนำให้ cache เป็นวิธีลดค่าใช้จ่ายนี้
- Scope, audience และ resource ขอ token หนึ่งตัวต่อหนึ่ง API วิธีมาตรฐานในการระบุ API คือ parameter
resource(RFC 8707) ถ้าไม่มี parameter นี้ RFC 9068 ให้ server หาaudจาก scope ที่ขอมา และ server ควรปฏิเสธ request ที่ scope ชี้ไปหลาย API แต่ละ vendor ทำต่างกัน: Auth0 รับ parameteraudienceที่เป็น identifier ของ API ส่วน Microsoft Entra ID รับscope=<resource>/.defaultแล้วใส่ application permission ที่ administrator ให้ไว้ลงใน claimrolesและ Cognito ออกให้แค่ custom scope (resource-server/scope) ที่ผูกกับ app client และไม่รับresourceสำหรับ grant นี้ - ใน API authorize จาก identity ของ client (
client_idหรือsub) คู่กับ scope หรือ role และต้องรู้ให้ได้ก่อนว่า token เป็นของ client หรือของผู้ใช้ แล้วค่อยเชื่อsubไม่มีอะไรใน token บอกว่าคนไหนเป็นต้นเหตุของการเรียก (ถ้ามี) ถ้า API ต้องรู้เรื่องนั้น แปลว่าใช้ grant ผิดตัว private_key_jwtassertion คือ JWT ที่ตั้งissและsubเป็น client ID มีaudระบุ authorization server มีexpสั้น ๆ และมีjtiที่ไม่ซ้ำ (RFC 7523, OpenID Connect Core §9) ส่งไปเป็นclient_assertionคู่กับclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearerถ้า server รองรับ ให้ registerjwks_uriแทน public key แบบตายตัว การ rotate key จะได้ไม่ต้องแก้อะไรที่ server ส่วน private key ให้เก็บไว้ใน KMS หรือ HSM ที่ sign ให้ตามคำขอ การ update RFC 7523 (draft-ietf-oauth-rfc7523bis ที่ approve แล้วและอยู่ในคิวของ RFC Editor ณ ตุลาคม 2026) ปิดช่อง audience-confusion attack:audต้องเป็น issuer identifier ของ authorization server เท่านั้น ไม่ใช่ URL ของ token endpoint อีกต่อไป และ JWT ควรมี type เป็นclient-authentication+jwtผลิตภัณฑ์ต่าง ๆ ยังตามไม่ทัน (อย่างคู่มือ service app ของ Okta ก็ยังใช้ URL ของ token endpoint เป็นaud) เพราะฉะนั้นให้ทำตามเอกสารของ server ที่ใช้- Mutual TLS ตัว
tls_client_authvalidate certificate chain และจับคู่กับ subject ที่ register ไว้หนึ่งตัว (DN หรือ SAN) ทำให้ certificate ที่ต่ออายุแล้วแต่ subject เดิมไม่ต้องแก้อะไรที่ server ส่วนself_signed_tls_client_authข้ามการตรวจ chain และจับคู่ certificate กับตัวที่ register ไว้ใน JWKS ของ client ถ้า TLS ไปจบที่ load balancer ตัว certificate ต้องไปถึง authorization server ในรูปแบบที่มันไว้ใจได้ และ RFC 8705 ปล่อยเรื่องนี้ไว้ให้แต่ละ deployment จัดการเอง - Workload identity federation platform ที่รัน workload รู้อยู่แล้วว่ามันคืออะไร และบอกเรื่องนั้นได้ใน signed token อายุสั้น: Kubernetes projected service-account token (ผูกกับ audience และ pod และ kubelet จะเปลี่ยนให้ใหม่เมื่อถึง 80% ของอายุหรือหลัง 24 ชั่วโมง เลยต้องอ่านไฟล์ใหม่) SPIFFE SVID จาก Workload API (X.509 certificate หรือ JWT ที่ได้มาโดยไม่ต้องมี bootstrap secret) หรือ token ของ cloud provider (workload บน AWS ขอ JWT จาก STS
GetWebIdentityTokenได้) authorization server จะถูกตั้งให้ไว้ใจ issuer นั้นสำหรับ subject และ audience หนึ่งชุด แล้ว workload ก็ยื่น token นั้นในตำแหน่งที่เคยต้องใช้ secret ส่วน Microsoft Entra ID จะรับมันเป็นclient_assertionของ client credentials request ธรรมดา โดยจับคู่กับ federated identity credential บนแอป ส่วน Google Cloud แลกมันที่ Security Token Service ตาม OAuth 2.0 Token Exchange (RFC 8693) แล้ว Keycloak 26.8 ก็รับ Kubernetes service-account token เป็น client assertion ได้ (feature นี้เปิดเป็น default) และรับ SPIFFE JWT-SVID ได้ในฐานะ preview feature ตอนนี้มี working-group draft ของ IETF ชื่อ OAuth SPIFFE Client Authentication ที่กำลังทำกรณี SPIFFE ให้เป็นมาตรฐาน - ถ้ายังใช้ secret อยู่ ให้ authorization server เป็นคน generate แล้วเก็บไว้ใน secret manager (ห้ามเก็บใน source, image หรืออะไรที่ส่งไปถึงผู้ใช้) และให้ทุก workload มี client ของตัวเอง ถ้ารั่วจะได้จำกัดวงและตามรอยได้ rotate ตามรอบโดยให้มี credential สองตัวที่ใช้ได้พร้อมกัน: Cognito ให้มี active secret สองตัวต่อ app client และ Auth0 ให้ register public key ได้สองตัว ตัวใหม่เลย roll out ได้ก่อนจะลบตัวเก่า ตั้ง alert ก่อน secret หรือ certificate จะหมดอายุ: credential ที่หมดอายุก็คือ outage
- ผลิตภัณฑ์ authorization server หลัก ๆ ทุกตัวรองรับ grant นี้ ที่ต่างกันคือจะใช้อะไรแทน secret ได้บ้าง Microsoft Entra ID รับ secret, assertion ที่ sign ด้วย key ของ certificate หรือ federated credential และเอกสาร MSAL ของมันก็ระบุว่าไม่แนะนำให้ใช้ secret ใน production ส่วน Auth0 รองรับ client secret, Private Key JWT (Enterprise plan) และ mutual TLS (Enterprise plan พร้อม add-on Highly Regulated Identity) ด้าน management API ของ Okta เองรับแค่
private_key_jwtจาก service app ส่วน Amazon Cognito authenticate app client ด้วย client secret ที่ token endpoint หรือผ่าน APIGetClientTokenของมัน แล้ว Keycloak ก็เปิด grant นี้ราย client ด้วย "Service accounts roles" และ authenticate client ได้ด้วย secret, signed JWT หรือ X.509 certificate - ตรวจ token ที่ไหน API gateway validate token ได้ก่อนที่ request จะไปถึง service ส่วนใน service mesh ตัว mutual TLS บอกอยู่แล้วว่า workload ไหนอยู่ปลายอีกฝั่งของ connection แต่ access token เพิ่ม scope และ audience เข้ามา และยังมีความหมายอยู่แม้ request จะผ่าน gateway หรือ proxy ที่ terminate TLS ไปแล้ว