Sessions and JWTs
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”sign-in สำเร็จหนึ่งครั้งจะได้ JWT access token อายุสั้นมาพร้อมกับ refresh token อายุยาวกว่า supabase-js จะเก็บและ refresh ทั้งคู่ให้เองแบบเงียบ ๆ และ claim ที่อยู่ใน JWT — decode และตรวจสอบจริงด้านล่างนี้ — คือสิ่งที่ Postgres ใช้ตัดสินว่า request หนึ่ง ๆ มีสิทธิ์เห็นอะไรบ้าง
Access token จริงจาก local ถอดรหัสเต็ม ๆ
หัวข้อที่มีชื่อว่า “Access token จริงจาก local ถอดรหัสเต็ม ๆ”sign-in สำเร็จจะได้ session ที่มี access_token (เป็น JWT) และ refresh_token decode token จริงที่ GoTrue ของคอร์สนี้ออกให้ — header.payload ไม่รวม signature — เห็น claim ทุกตัวที่ auth.jwt() อ่านได้ภายใน RLS policy:
header: {"alg":"ES256","kid":"b81269f1-21d8-4f2e-b719-c2240a840d90","typ":"JWT"}payload: { "iss": "http://127.0.0.1:55321/auth/v1", "sub": "64721d1e-dd1d-4672-bdb0-519dcde3c455", "aud": "authenticated", "exp": 1790660782, "iat": 1790657182, "email": "[email protected]", "app_metadata": {"provider": "email", "providers": ["email"]}, "user_metadata": {"email": "[email protected]", "email_verified": true, "phone_verified": false}, "role": "authenticated", "aal": "aal1", "amr": [{"method": "password", "timestamp": 1790657182}], "session_id": "dab49039-ccbe-4cc9-a57d-1250ece5e491", "is_anonymous": false}แต่ละ claim มีหน้าที่ของตัวเอง: sub คือสิ่งที่ auth.uid() อ่าน; role คือสิ่งที่ auth.role() อ่านและเป็นสิ่งที่ Postgres/PostgREST เอาไป set role จริง ๆ; aud (audience) ต้องเป็น authenticated ถึงจะให้ PostgREST ยอมรับ token เลย; exp/iat คือ Unix timestamp มาตรฐานของ expiry/issued-at ตาม JWT; session_id ผูก token เข้ากับ sign-in session เดียวเจาะจง (ใช้โดย refresh rotation ด้านล่าง); aal (Authenticator Assurance Level) เป็น aal1 สำหรับ factor เดียว และ aal2 เมื่อทำ MFA เสร็จแล้ว; amr (Authentication Methods Reference) คือ array ที่บันทึกว่า user ยืนยันตัวตนยังไงและเมื่อไหร่ — password ในที่นี้ แต่จะมี entry ที่สองโผล่มาถ้า session ถูกอัปเกรดด้วย MFA ทีหลัง; is_anonymous คือสิ่งที่ทำให้ policy กัน anonymous session ออกได้เจาะจง (พิสูจน์แล้วในบทก่อนหน้า)
Asymmetric signing: default จริงตั้งแต่ 1 ตุลาคม 2025
หัวข้อที่มีชื่อว่า “Asymmetric signing: default จริงตั้งแต่ 1 ตุลาคม 2025”ตาม changelog นี่ไม่ใช่คำอธิบายฟีเจอร์แบบ opt-in อีกต่อไปแล้ว — เป็น default ที่เสร็จสมบูรณ์แล้ว: “Starting October 1, 2025, all new projects will use asymmetric JWTs by default. Existing projects can opt-in at any time.” (supabase.com/blog/jwt-signing-keys, 2025-07-14) header จริงข้างบนก็แสดงให้เห็นอยู่แล้ว: "alg":"ES256" พร้อม kid (key ID) — signature แบบ elliptic-curve asymmetric ตรวจสอบได้ด้วย public key ไม่ใช่ shared secret /.well-known/jwks.json จริงของ local stack ยืนยัน key เดียวกัน:
curl -s "$API_URL/auth/v1/.well-known/jwks.json"{"keys":[{"alg":"ES256","crv":"P-256","ext":true,"key_ops":["verify"],"kid":"b81269f1-21d8-4f2e-b719-c2240a840d90","kty":"EC","use":"sig","x":"M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4","y":"P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ"}]}Refresh token rotation: error จริงของการตรวจจับการ reuse
หัวข้อที่มีชื่อว่า “Refresh token rotation: error จริงของการตรวจจับการ reuse”ทุกการเรียก /auth/v1/token?grant_type=refresh_token ที่สำเร็จจะคืน refresh token ใหม่ และทำให้อันที่เพิ่งใช้ไปใช้ไม่ได้อีก — rotation ไม่ใช่ reuse refresh_token_reuse_interval (10 วินาทีบน config.toml ของ local stack นี้) คือ grace window สั้น ๆ: การเอา refresh token ที่เพิ่งใช้ไปก่อนหน้าทันทีมาใช้ซ้ำภายใน window นั้นจะได้รับการยอมรับ (คืน session generation ถัดไปเดิมซ้ำ ไม่ error) เพราะแอปจริงมี retry request และ network สะดุดนิดหน่อยไม่ควรทำให้เสีย session พิสูจน์ขอบเขตจริง: ใช้ refresh token ที่ไม่ใช่แค่อันล่าสุด แต่เก่าไปสองรุ่นแล้ว:
# token_1 -> refresh -> token_2 (rotation, succeeds)# token_2 -> refresh -> token_3 (rotation, succeeds)# token_1 (now 2 generations old) -> refresh again:curl -s -X POST "$API_URL/auth/v1/token?grant_type=refresh_token" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"refresh_token":"<token_1, already superseded twice>"}'{"code":400,"error_code":"refresh_token_already_used","msg":"Invalid Refresh Token: Already Used"}error refresh_token_already_used จริง เฉพาะเจาะจง — ไม่ใช่ 401 ทั่วไป บน GoTrue configuration ของ local นี้ การเอา token ที่เก่าจริง ๆ มาใช้ถูกปฏิเสธด้วยตัวเอง แต่ refresh token ปัจจุบันที่ยังถูกต้องอยู่ในสาย chain ที่ถูกต้องยังใช้งานได้ต่อทันทีหลังจากนั้นใน proof session เดียวกันนี้ — ควรบอกให้ชัดเจนแทนที่จะสมมติเอาเอง: พฤติกรรม local นี้ปฏิเสธแค่ตัว token ที่ถูก reuse เอง โดยไม่จำเป็นต้อง revoke ทั้ง session family ที่ active อยู่ อย่าสมมติว่า “ตรวจจับ reuse ได้แล้ว” แปลว่า “token ทุกตัวของ session นี้ตายหมดแล้ว” เสมอไป โดยไม่เช็คพฤติกรรมจริงของ project ตัวเองก่อน
getSession() vs getUser(): พิสูจน์ด้วย token ที่ถูกปลอม
หัวข้อที่มีชื่อว่า “getSession() vs getUser(): พิสูจน์ด้วย token ที่ถูกปลอม”getSession() อ่าน session ตรงจาก local storage และไม่ตรวจสอบ signature ของ JWT เลย — เชื่อทุกอย่างที่เก็บไว้ตรง ๆ getUser() ส่ง token ไปให้ Auth server จริง (GET /auth/v1/user) ซึ่งตรวจสอบ signature ทาง cryptographic ก่อนคืนอะไรกลับมา ความต่างนี้สำคัญที่สุดตอนที่ token ถูกปลอม เอา access token จริงที่ใช้ได้มาแก้ claim sub กับ role ฝั่ง client เก็บ signature เดิม (ที่ตอนนี้ไม่ตรงกับ payload แล้ว) ไว้ — เหมือนกับสิ่งที่การ decode local แบบ getSession() ทำเป๊ะ และเหมือนกับสิ่งที่ attacker จริงที่คุม client storage ได้จะลอง:
# (Node one-liner: decode the real token, flip sub to Bob's uid and role to# service_role, re-encode header.payload, keep the OLD signature untouched)getSession()-style (decode local เท่านั้น ไม่มีการตรวจสอบ):{"sub":"8f07c3e8-0ce3-4d88-b1b7-75c93cca74ec","role":"service_role", ...} ^ claim ที่ถูกปลอมถูกเชื่อทั้งหมด — นี่คือสิ่งที่ client-side check ที่อ่าน JWT ที่ cache ไว้จาก getSession() จะเห็น
getUser()-style (GET /auth/v1/user ด้วย token ที่ถูกปลอมตัวเดียวกัน):{"code":403,"error_code":"bad_jwt","msg":"invalid JWT: unable to parse or verify signature, token signature is invalid: crypto/ecdsa: verification error"}token ที่ถูกปลอมอ้างว่าเป็น service_role และเป็นคนละ user ไปเลย — และโค้ดที่เรียกแค่ getSession() แล้วเชื่อ field .user ของมันจะเชื่อไปด้วย Auth server จริงปฏิเสธมันตรง ๆ เพราะ signature ไม่ตรงกับ payload ที่มันถูกออกมาตั้งแต่แรกอีกต่อไป นี่คือเหตุผลทั้งหมดที่ getUser() (หรือรุ่นใหม่ที่เร็วกว่าอย่าง getClaims() ด้านล่าง) — ไม่ใช่ getSession() เดี่ยว ๆ — ต้องเป็นตัวคุมอะไรก็ตามที่สำคัญ: การเช็ค admin, การกั้น feature เสียเงิน, การตัดสินใจ redirect ฝั่ง server
@supabase/ssr: pattern ของ cookie
หัวข้อที่มีชื่อว่า “@supabase/ssr: pattern ของ cookie”client แบบ browser-only เก็บ session ไว้ใน localStorage ซึ่ง server component หรือ route handler อ่านไม่ได้ @supabase/ssr เชื่อมสองฝั่งด้วย client แบบ cookie-based ที่ทั้ง browser และ server code สร้างแบบเดียวกัน อ่าน/เขียน cookie เดียวกัน:
import { createServerClient } from '@supabase/ssr';import { cookies } from 'next/headers';
export async function createClient() { const cookieStore = await cookies();
return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ); } catch { // called from a Server Component with no way to set cookies — // safe to ignore if middleware is refreshing the session } }, }, } );}setAll ถูกเรียกทุกครั้งที่ library ต้องเขียน cookie กลับ — บ่อยที่สุดคือทันทีหลัง token refresh เพราะ refresh token ที่ rotate แล้วต้องไปถึง browser ไม่งั้น request ถัดไปจะยื่น token ที่ถูกแทนที่ไปแล้ว (และตามที่พิสูจน์ข้างบน จะถูกปฏิเสธ) ซ้ำ session cookie ชื่อ sb-<project-ref>-auth-token โดย default (supabase.com/docs/guides/auth/server-side/creating-a-client) — cookie เดียว อ่านเหมือนกันทุกที่ ทั้ง Server Component, Route Handler และ middleware ต่าง createServerClient แบบเดียวกันจาก cookie ของ request เดียวกัน
auth.jwt() ภายใน RLS กับการจัดการ exp
หัวข้อที่มีชื่อว่า “auth.jwt() ภายใน RLS กับการจัดการ exp”สองบทก่อนหน้าพิสูจน์แล้วว่า auth.uid(), auth.role(), auth.jwt() คืนค่าจริง และใช้ custom claim tenant_id ภายใน policy condition — JWT ตัวเดียวกันเป๊ะที่ decode ไว้ข้างบนคือสิ่งที่ function พวกนั้นอ่านฝั่ง server ทุก request exp ไม่ต้องจัดการเองด้วยมือเลยตลอด chain นี้: PostgREST กับ GoTrue ทั้งคู่ปฏิเสธการตรวจ signature ของ token ที่หมดอายุตั้งแต่ระดับ HTTP ก่อนที่ policy ไหนจะรันด้วยซ้ำ และ getSession() ของ supabase-js จะ refresh token ที่ใกล้ exp ล่วงหน้าก่อนคืนให้เสมอ ดังนั้นโค้ดแอปปกติไม่ต้องเทียบ exp กับเวลาปัจจุบันเองเลย ข้อยกเว้นเดียวคือ edge case ของ token ที่ถูกปลอม/หมดอายุที่บทนี้พิสูจน์ไว้เป๊ะ ๆ ที่การข้าม SDK ไป (raw fetch, decode มือเอง) ก็ข้ามการจัดการอัตโนมัตินั้นไปด้วย
flowchart LR
signin["User signs in"] --> issue["Auth issues ES256 access token + refresh token"]
issue --> attach["supabase-js attaches JWT to every API request"]
attach --> pg[("Postgres / PostgREST reads JWT claims")]
pg --> uid["auth.uid() / auth.role() / auth.jwt() available inside RLS policies"]
issue --> rotate["Refresh rotates the token; reuse of a stale one: real error"] อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ถูกขอให้ “ป้องกัน route ของ admin dashboard นี้ฝั่ง server” แล้วสร้างโค้ดนี้มา:
🐛 หา bug ในโค้ดนี้
import { createClient } from '@/lib/supabase-server';import { redirect } from 'next/navigation';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {const supabase = await createClient();const { data: { session },} = await supabase.auth.getSession();
if (!session || session.user.app_metadata?.role !== 'admin') { redirect('/login');}
return <>{children}</>;}เฉลย
ดูสมเหตุสมผลดี — เช็ค session แล้วก็เช็ค role admin ก่อน render บั๊กคือ getSession() เองในจุดกั้นความปลอดภัยฝั่ง server: มันไม่ตรวจสอบ signature ของ JWT เลยสักนิด แค่อ่าน claim ที่อยู่ใน session cookie ตรง ๆ พิสูจน์ตรง ๆ ข้างบน — token ที่ถูกปลอม payload ของ role/claim ผ่าน getSession() check ไปได้เฉย ๆ พร้อมค่าที่ปลอมไว้ครบ มีแค่การส่ง token ไปให้ Auth server จริง (getUser()) หรือตรวจสอบทาง cryptographic (getClaims()) เท่านั้นที่จะจับการปลอมนี้ได้ ตรงตามที่พิสูจน์ด้วย error bad_jwt จริงของการตรวจ signature ก่อนหน้าในบทนี้เป๊ะ ทางแก้คือสลับบรรทัดเดียว: const { data, error } = await supabase.auth.getUser(); if (error || !data.user || data.user.app_metadata?.role !== 'admin') redirect('/login'); — logic เดิมเป๊ะ แต่ตอนนี้มีการตรวจสอบ signature ที่ server ยืนยันจริงรองรับอยู่ แทนที่จะเชื่อ byte อะไรก็ตามที่อยู่ใน cookie
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”JWT คือ claim บวก signature — claim เป็นแค่ JSON ที่ใครก็อ่านและแก้ได้อิสระถ้าถือ byte อยู่ในมือ และสิ่งเดียวที่กั้นระหว่าง “แก้ได้อิสระ” กับ “เชื่อถือได้” คือมีอะไรตรวจสอบ signature ใหม่ก่อนเชื่อ claim นั้นหรือเปล่า getSession() ข้ามการตรวจนั้นไปโดยตั้งใจ (ออกแบบมาให้เร็วและ local สำหรับจุดประสงค์ UI แบบ “โชว์ nav bar ที่ signed-in โดยไม่ต้อง round trip”); getUser()/getClaims() ตรวจสอบจริง แลกกับ network call หรือการตรวจสอบทาง cryptographic ในเครื่องเทียบกับ public key พิสูจน์ทุกอันในบทนี้จริง ๆ คือไอเดียเดียวที่ใส่เสื้อผ้าคนละแบบ: signature ที่ไม่ตรงกับ claim ของมันไม่มีค่าอะไรเลยไม่ว่า claim จะดูเป็นทางการแค่ไหน และ Postgres/PostgREST/GoTrue จะเชื่อ JWT ก็ต่อเมื่อตรวจ signature นั้นแล้วเท่านั้น — ไม่เคยแค่อ่าน JSON แล้วหวังว่าจะถูก
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- Asymmetric (
ES256) signing ให้ service ไหนก็ตรวจสอบ token ในเครื่องได้ด้วย public key โดยไม่ต้องกระจาย shared secret ไปที่ไหนเลย — แลกกับส่วนที่เคลื่อนไหวเพิ่มขึ้นอีกหนึ่งอย่าง (JWKS endpoint, key rotation) เทียบกับ sharedHS256secret ตัวเดียว - Refresh token rotation พร้อม reuse-grace window สั้น ๆ ทนต่อ network retry ในโลกจริงได้ — แลกกับ attacker จริงที่ขโมย refresh token ได้ก็ได้ grace window สั้น ๆ เดียวกันก่อนที่การ reuse จะถูกตรวจจับ ไม่ใช่ศูนย์
getSession()เป็น local-only ทำให้เร็วพอที่จะเรียกทุก render เพื่อจุดประสงค์ UI — แลกกับการเป็นจุดกั้นความปลอดภัยที่ไม่ปลอดภัยจริง ๆ พิสูจน์ตรง ๆ ข้างบน- cookie-based session ของ
@supabase/ssrทำงานเหมือนกันทั้ง Server Component, Route Handler และ middleware — แลกกับsetAll/cache-header ต้องถูกต้องเป๊ะ ไม่งั้น session desync กันเงียบ ๆ ระหว่าง browser กับ server (คำเตือนซ้ำ ๆ ของ docs เองเรื่อง user ถูก log out แบบสุ่มเมื่อgetClaims()/refresh handling ต่อผิด)
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Asymmetric JWT signing key ยังเป็น opt-in migration อยู่” แก้ไขตรงนี้ด้วยวันที่ชัดเจน: ตั้งแต่ 1 ตุลาคม 2025 project ใหม่ ทุกอันของ Supabase default เป็น asymmetric signing แล้ว มีแค่ project ที่มีอยู่แล้วเท่านั้นที่ต้อง opt-in เอง
- “
getSession()กับgetUser()ให้ความน่าเชื่อถือเท่ากัน แค่ความเร็วต่างกัน” พิสูจน์แล้วว่าไม่จริง: claim ที่ถูกปลอมของ token ผ่านgetSession()ไปได้เลยโดยไม่มีใครรู้ตัว แต่ถูกgetUser()ปฏิเสธตรง ๆ — ความต่างด้านความเร็วมีจริง แต่ความต่างด้านความน่าเชื่อถือก็มีจริงเหมือนกัน และมีแค่อันเดียวเท่านั้นที่เป็น security check ฝั่ง server ที่ใช้ได้จริง - “API key
anonกับservice_roleใช้ signing scheme เดียวกับ user session จริง” พิสูจน์แล้วว่าไม่จริงด้วยการ decode ทั้งคู่: static key เป็นHS256แบบเก่า ไม่มีkidส่วน session token จริงเป็นES256พร้อมkidที่ตรงกับ JWKS endpoint จริง — สองกลไกอยู่ร่วมกันบน project เดียวกัน - “refresh token ที่ ‘reuse’ ทันทีจะ error เสมอ” พิสูจน์แล้วว่าไม่จริง: มี grace window สั้น ๆ ปรับได้ (
refresh_token_reuse_interval) อยู่โดยตั้งใจ เพื่อไม่ให้การ retry request เดียวกันทันทีทำให้ user เสีย session — มีแค่ token ที่เก่าจริง ๆ (หลายรุ่นก่อนหน้า) เท่านั้นที่ทำให้เกิด error reuse จริง