Verify the Supabase JWT
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”app/auth.py — ที่เดียวที่ FitTrack ตัดสินใจว่า ใครกำลังเรียก client ทั้งสอง (Flutter และ Svelte) sign in ผ่าน Supabase Auth แล้วรับ JWT จากนั้นส่ง token นั้นทุก request ไป FastAPI ในฐานะ header Authorization: Bearer <token> บทนี้เขียนโค้ดที่เปิด token นั้น, check signature กับ signing key ของ Supabase, แล้วยื่น user id ธรรมดาให้ app ที่เหลือ — หรือปฏิเสธ request ด้วย 401
สิ่งที่ส่งมอบคือ FastAPI dependency ที่ใช้ซ้ำได้ตัวเดียว, get_current_user, บวก type alias CurrentUser เพื่อให้ handler ใดก็ตาม require user ที่ login แล้วได้ด้วยการเพิ่ม parameter เดียว เพื่อพิสูจน์ว่าทำงานครบวงจร เราต่อสาย dependency นี้เข้า route ที่ป้องกันไว้ตัวแรก, GET /me, ที่แค่ echo id ของผู้เรียก — The current user → เปลี่ยน stub นั้นเป็นการ lookup profile จริง
Supabase authenticate user ไปแล้ว — แล้วทำไม FastAPI ถึง check token อีกครั้ง? เพราะ token คือสิ่งเดียวที่ FastAPI ได้รับ ฝั่ง client login (email + password, magic link, OAuth) กับ Supabase แล้วได้ JWT ที่ sign แล้วกลับมา FastAPI ไม่เคยเห็น password และไม่เคยเรียก Supabase เพื่อถามว่า “คนนี้จริงไหม” ทุก request แทนที่ Supabase sign token ด้วย key ที่มีแต่ Supabase ที่ถือ private half ไว้ ถ้า FastAPI verify signature นั้นได้ ก็รู้ได้ว่า token ถูก mint โดย Supabase และไม่ถูกดัดแปลง การ check ทาง cryptography ตัวเดียวนั้น คือ authentication ทั้งหมด และเร็วมาก (ไม่มี database hit และไม่มี network call ต่อ request หลัง cache signing key แล้ว), stateless, และนั่นคือเหตุผลว่าทำไม JWT ถึงคุ้มค่าที่จะใช้
Supabase project รุ่นปัจจุบัน sign user session ใหม่ด้วย key pair แบบ asymmetric — ES256 (elliptic-curve) — แล้วเผยแพร่ public half ที่ endpoint JWKS ที่รู้จักกันดี, {SUPABASE_URL}/auth/v1/.well-known/jwks.json ใครก็ดึง public key นั้นมา verify signature ได้; มีแต่ Supabase เท่านั้นที่ถือ private key ที่ สร้าง signature ได้ project ยังพก session ที่ sign แบบเก่าไว้ได้ด้วย ด้วย shared secret (HS256, SUPABASE_JWT_SECRET จาก supabase status) — secret เดียวกันทั้ง sign และ verify จึงต้องเก็บไว้ server-only เท่านั้น เพราะ header ของ token เองบอกว่าใช้แบบไหน get_current_user จึงอ่าน header ก่อนแล้ว verify ตามแบบที่ควรทำ: shared secret สำหรับ HS256, public key จาก JWKS สำหรับที่เหลือ
jwt.decode ทำสามอย่างในหนึ่ง call และทุกอย่างสำคัญ:
algorithms=[...]— list ของ algorithm ที่คุณยอมรับ ระบุชัด ๆ เสมอ การ pin ไว้เป็น security requirement ไม่ใช่พิธีการ: ถ้าไม่ระบุ attacker ยื่น token ที่อ้างalg: noneให้คุณได้ และ PyJWT มีสิทธิ์ที่จะข้ามการ check signature คุณระบุ algorithm ที่คุณยอมรับแล้วปฏิเสธที่เหลือทั้งหมด — ไม่เอา allow-list มาจาก header ของ token เอง- key — เลือกระหว่าง
settings.supabase_jwt_secret(สำหรับ tokenHS256รุ่นเก่า) หรือ public key ที่PyJWKClientดึงมาจาก JWKS endpoint (สำหรับ tokenES256รุ่นปัจจุบัน) จับคู่ด้วย headerkidของ token shared secret เป็นค่า server-only และต้องไม่ ship ใน client build เด็ดขาด; ส่วน public key จาก JWKS ถูกออกแบบมาให้เผยแพร่ได้อยู่แล้ว audience="authenticated"— Supabase ประทับ token ของ signed-in user ทุกตัวด้วยaud: "authenticated"การ verify audience หมายถึง token ที่ mint มาเพื่อวัตถุประสงค์ อื่น replay กับ API ของคุณไม่ได้ ถ้า audience ไม่ตรง PyJWT จะ raise ทันที
ถ้า check ใดล้มเหลว — signature ผิด, token หมดอายุ, audience ผิด, input ขยะ — PyJWT จะ raise และเราแปลทุกกรณีเป็น 401 Unauthorized เดียว เราตั้งใจไม่ leak ว่าทำไม ถึงล้มเหลว; “invalid token” คือทั้งหมดที่ผู้เรียกต้องรู้
สุดท้าย ทั้งหมดนี้อยู่หลัง dependency injection ของ FastAPI แทนที่จะเป็น helper ที่คุณเรียกเอง dependency เป็น declarative: route ที่ต้องการ user เขียน user_id: CurrentUser ใน signature ของตัวเอง และ FastAPI รัน check ก่อน body ของ handler, inject ผลลัพธ์, และ — เป็นโบนัส — แสดง security scheme ใน /docs ที่ generate ออกมา การลืมป้องกัน route กลายเป็นการขาดหายที่มองเห็นได้ใน signature ไม่ใช่ function call ที่หายไปฝังอยู่ใน body
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Verify JWT แบบ local (stateless) เทียบกับ เรียก Supabase เพื่อ validate ทุก request
- Pros: ไม่มี network round-trip และไม่มี database hit ต่อ request — การ check เป็น CPU ล้วนกับ secret ที่คุณถืออยู่แล้ว จึง scale ได้แบบ trivial และ latency แบน; backend ไม่มี runtime dependency บน Supabase ให้เอื้อมถึงได้เพื่อ authorize request
- Cons: เพราะเป็น stateless, token ยังใช้ได้จน หมดอายุ — คุณ revoke token เดียวฝั่ง server ทันทีไม่ได้แบบที่ session lookup ทำได้ Supabase บรรเทาด้วย access token อายุสั้นบวก refresh token; สำหรับ scope ของ FitTrack trade-off คุ้มชัดเจน แต่ต้องเข้าใจข้อจำกัดนี้ก่อนจะพึ่ง JWT
get_current_user dependency เทียบกับ decode token inline ในทุก handler
- Pros: logic การ verify มีอยู่ครั้งเดียว; ทุก route ที่ป้องกันไว้ opt-in ด้วย typed parameter เดียวและได้
401behavior เดียวกันและ affordance “Authorize” ใน/docsเดียวกันอัตโนมัติ; การ test ทีหลัง override dependency เพื่อ inject fake user ได้โดยไม่แตะ route ใด - Cons: dependency เป็น concept เฉพาะ FastAPI ที่มือใหม่ต้องเรียน และ indirection หมายถึงการ check ไม่เห็นใน body ของ handler — คุณต้องอ่าน signature เพื่อรู้ว่า route ถูกป้องกัน นั่นเป็นราคาเล็ก ๆ สำหรับการไม่ทำโค้ด security-critical ซ้ำข้ามสิบกว่า endpoint
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. เพิ่ม PyJWT
หัวข้อที่มีชื่อว่า “1. เพิ่ม PyJWT”uv add "pyjwt[crypto]"PyJWT คือ library encode/decode; เราใช้แค่ decode ที่นี่ extra [crypto] ดึง package cryptography มาด้วย ซึ่ง PyJWT ต้องใช้เพื่อ check signature ES256 — ถ้าไม่มี, การ verify token ของ Supabase รุ่นปัจจุบันจะ raise MissingCryptographyError แทนที่จะเป็น 401 สะอาด ๆ ตามที่บทนี้สร้างไว้
2. app/auth.py
หัวข้อที่มีชื่อว่า “2. app/auth.py”# app/auth.py — turn a Supabase Bearer token into a verified user id.from typing import Annotatedfrom uuid import UUID
import jwtfrom fastapi import Depends, HTTPException, statusfrom fastapi.security import HTTPAuthorizationCredentials, HTTPBearerfrom jwt import PyJWKClient
from app.config import settings
# auto_error=False so a MISSING header reaches us as `None` instead of# FastAPI raising its own 403 — we want one consistent 401 for every# authentication failure, present-but-bad or absent alike.bearer_scheme = HTTPBearer(auto_error=False)
# Supabase signs new user sessions with an asymmetric key (ES256) and# publishes the public half at this well-known JWKS endpoint — the same# path for local and hosted Supabase. PyJWKClient fetches and caches it,# then picks the right key by the token's `kid` header._jwks_client = PyJWKClient(f"{settings.supabase_url}/auth/v1/.well-known/jwks.json")
def _unauthorized(detail: str) -> HTTPException: """Every auth failure returns the same 401 with a Bearer challenge.""" return HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, headers={"WWW-Authenticate": "Bearer"}, )
def get_current_user( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],) -> UUID: """Verify the Supabase JWT and return the user id (the `sub` claim).
Raises 401 if the header is missing, the signature is wrong, the token has expired, or the audience isn't `authenticated`. """ if credentials is None: raise _unauthorized("Missing bearer token")
token = credentials.credentials
try: # A project can carry legacy sessions signed with the shared secret # (HS256) alongside current ones signed with the asymmetric project # key (ES256) — the token's own header says which, so branch on # that instead of guessing or hardcoding one algorithm. header = jwt.get_unverified_header(token) if header.get("alg") == "HS256": signing_key = settings.supabase_jwt_secret else: signing_key = _jwks_client.get_signing_key_from_jwt(token).key payload = jwt.decode( token, signing_key, algorithms=["HS256", "ES256"], audience="authenticated", ) except jwt.PyJWTError: # One catch-all: don't leak *why* verification failed. raise _unauthorized("Invalid or expired token")
sub = payload.get("sub") if sub is None: raise _unauthorized("Token is missing the subject claim")
try: # Supabase's `sub` is the user's UUID; parse it so it compares # directly against the uuid FK columns every table keys on. return UUID(sub) except ValueError: raise _unauthorized("Token subject is not a valid user id")
# A ready-made annotation so routes can just write `user_id: CurrentUser`.CurrentUser = Annotated[UUID, Depends(get_current_user)]บันทึกไฟล์นี้เป็น app/auth.py โดยไฟล์นี้อ่าน settings.supabase_url มาสร้าง JWKS URL และ settings.supabase_jwt_secret สำหรับ fallback รุ่นเก่า ทั้งคู่มาจาก Settings ที่คุณสร้างใน App & config → — ตรวจให้แน่ใจว่าตั้งทั้ง SUPABASE_URL และ SUPABASE_JWT_SECRET ไว้ใน .env ของคุณ
3. protected route ตัวแรก
หัวข้อที่มีชื่อว่า “3. protected route ตัวแรก”เพิ่ม GET /me เข้า app/main.py ที่ require dependency ตอนนี้ยังแค่ return id — พิสูจน์ว่าทั้งสายทำงานก่อนเราจะเพิ่มการ lookup database ในบทถัดไป:
from uuid import UUID
from fastapi import FastAPI
from app.auth import CurrentUser
app = FastAPI(title="FitTrack API")
@app.get("/health")def health() -> dict[str, str]: """Liveness check — no auth, no database, just proof the app is up.""" return {"status": "ok"}
@app.get("/me")def read_me(user_id: CurrentUser) -> dict[str, UUID]: """Whoami — proves the JWT dependency runs before we add the DB lookup.""" return {"user_id": user_id}นั่นคือการต่อสายทั้งหมด: user_id: CurrentUser คือทั้งหมดที่ต้องใช้เพื่อทำให้ route require Supabase token ที่ valid return type ตรงนี้สำคัญ ไม่ใช่แค่เพื่อ documentation: FastAPI ใช้ return annotation ของ route เป็น response model ดังนั้น -> dict[str, UUID] (ไม่ใช่ dict[str, str]) คือสิ่งที่ทำให้มัน serialize UUID ที่ get_current_user return ออกมาได้ — ถ้า annotate เป็น str จะ raise ResponseValidationError ทันทีที่มี request จริงเข้ามา เพราะค่าที่ถืออยู่คือ UUID ไม่ใช่ str
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”start API:
uv run fastapi dev app/main.pyตอนนี้ขอ token จริง จาก local Supabase ของคุณ นี่ใช้ password grant กับ local Auth server (port 54321 จาก supabase start); คำสั่งนี้สมมติว่าคุณสร้าง user ใน Supabase module — ถ้ายัง เพิ่มหนึ่งใน Supabase Studio ก่อน jq แยก access token ออกมาเป็นตัวแปร shell:
TOKEN=$(curl -s "http://127.0.0.1:54321/auth/v1/token?grant_type=password" \ -H "apikey: $SUPABASE_ANON_KEY" \ -H "Content-Type: application/json" \ -d '{"email":"lifter@example.com","password":"password123"}' | jq -r .access_token)เรียก route ที่ป้องกันไว้ พร้อม token — คุณได้ user id กลับมา:
curl -s localhost:8000/me -H "Authorization: Bearer $TOKEN"{"user_id":"3f4a1c2e-9b7d-4e1a-8c6f-2d5b0a1e7c93"}ทีนี้ลองเรียกแบบ ไม่มี token และด้วย token ขยะ — ทั้งคู่ต้องโดนปฏิเสธด้วย 401:
curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/mecurl -s -o /dev/null -w "%{http_code}\n" localhost:8000/me -H "Authorization: Bearer not-a-real-token"401401สุดท้าย เปิด http://localhost:8000/docs: GET /me ตอนนี้แสดงแม่กุญแจ และปุ่ม Authorize โผล่ที่ด้านบน — paste token ตรงนั้นแล้ว “Try it out” จะส่ง token ให้เอง แม่กุญแจนั้นคือ dependency ที่โฆษณาตัวเอง; ทุก route ที่คุณป้องกันจากนี้ได้การปฏิบัติเดียวกันฟรี ๆ
ตรวจสอบความเข้าใจ:
- FastAPI ไม่เคยเห็น password ของ user และไม่เคยเรียก Supabase ต่อ request ข้อมูลชิ้นเดียวอะไรที่ทำให้ FastAPI trust token ได้ และต้องเก็บค่านั้นไว้ที่ไหน?
- ทำไมเราถึง pass
algorithms=["HS256", "ES256"]ชัด ๆ แทนที่จะให้ PyJWT เชื่อalgที่ header ของ token เองอ้าง? audience="authenticated"ป้องกันอะไร และ PyJWT ทำอะไรถ้าaudของ token ไม่ตรง?- เราตั้ง
HTTPBearer(auto_error=False)และจัดการ header ที่หายไปเอง response code จะเป็นอะไรสำหรับAuthorizationheader ที่ หายไป ถ้าเราปล่อยauto_error=Trueและทำไมเราไม่ต้องการแบบนั้น?
app/auth.py คือ authentication boundary ของ FitTrack client authenticate กับ Supabase Auth, รับ JWT, แล้วส่งเป็น Bearer token; FastAPI re-verify token นั้นแบบ local ด้วย PyJWT — header ของ token บอกว่าเป็น session ES256 รุ่นปัจจุบัน (verify กับ public key ที่ PyJWKClient ดึงมาจาก JWKS endpoint ของ Supabase) หรือ HS256 รุ่นเก่า (verify กับ SUPABASE_JWT_SECRET ที่แชร์กัน) ทั้งคู่ check ด้วย audience="authenticated" — แล้วดึง user id ออกจาก sub claim การล้มเหลวใด ๆ (หายไป, ผิดรูป, หมดอายุ, signature ผิด, audience ผิด) ยุบเหลือ 401 เดียว ทั้งหมดถูกห่อเป็น dependency get_current_user และ alias CurrentUser ดังนั้น route require user ที่ login แล้วด้วย typed parameter เดียว และแม่กุญแจโผล่ใน /docs อัตโนมัติ เราพิสูจน์ด้วยการขอ token จริงจาก local Supabase แล้วดู /me ยอมรับ token นั้นและปฏิเสธที่เหลือทั้งหมด ต่อไป The current user → เปลี่ยน stub /me นั้นเป็น endpoint จริงที่ lookup profile ใน database และแสดง pattern ที่ทุก protected route ใน FitTrack จะทำตาม