การป้องกัน Token Refresh Race Condition ใน Single-Page Application
ผู้ใช้ถูก logout โดยไม่มีคำอธิบาย session token ดูถูกต้องใน localStorage, request ที่สำเร็จครั้งล่าสุดเกิดขึ้นเมื่อไม่กี่วินาทีก่อน และ pattern นั้นน่ารำคาญมากเพราะไม่สม่ำเสมอ สาเหตุที่แท้จริงมักเป็นแบบเดิมเสมอ: token refresh race condition
กลไกนั้นตรงไปตรงมาเมื่อคุณระบุได้ JWT หมดอายุ เมื่อ token หมดอายุระหว่าง session พฤติกรรมที่ถูกต้องคือการแลก refresh token กับ access token ใหม่และ retry request เดิมอย่างโปร่งใส แต่ในความเป็นจริง React application แทบไม่ค่อย make request ทีละตัว หน้า dashboard อาจ fetch ข้อมูลผู้ใช้, สรุปบัญชี, จำนวน notification และ activity ล่าสุดพร้อมกัน ถ้า access token หมดอายุในระหว่างนั้น request ทุกตัวได้รับ 401 ทุกตัว detect token หมดอายุอิสระกัน ทุกตัวพยายาม call refresh endpoint call แรกสำเร็จและ refresh token เก่าถูกใช้ไป call ที่สองพยายามใช้ refresh token เดิม ซึ่งตอนนี้ invalid แล้วและล้มเหลว ผู้ใช้ถูก logout
Singleton Promise Pattern
วิธีแก้ที่ดีที่สุดบันทึก in-flight refresh เป็น promise และ return promise เดิมนั้นให้กับทุก caller จนกว่ามันจะ resolve แทนที่แต่ละ caller จะเริ่ม refresh ใหม่อิสระกัน พวกมันทั้งหมด await operation ที่แชร์กันเดียว:
let refreshPromise: Promise<string> | null = null;
async function getValidAccessToken(): Promise<string> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (token && \!isTokenExpired(token)) {
return token;
}
if (\!refreshPromise) {
refreshPromise = supabase.auth
.refreshSession()
.then(({ data, error }) => {
if (error || \!data.session) {
throw error ?? new Error("Token refresh failed");
}
return data.session.access_token;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
Block finally คือรายละเอียดสำคัญ: มัน clear shared promise reference หลัง resolution หรือ rejection เพื่อให้ refresh cycle ถัดไปเริ่มต้นสะอาด
Interceptor Queue Approach
สำหรับ application ที่ใช้ Axios, pattern มาตรฐาน wrap serialization logic เดิมไว้ใน response interceptor วิธีนี้จัดการ retry อัตโนมัติ — request ที่ล้มเหลวจะถูก queue, refresh เสร็จสิ้น และ queue จะถูก drain พร้อม token ใหม่ที่ใช้กับ pending request config แต่ละตัวก่อนที่จะ retry
Supabase จัดการอะไรได้ — และอะไรไม่ได้
ขอบเขตการป้องกันของ serialization ภายในของ Supabase client ครอบคลุม call ที่ทำผ่าน method ของ client เอง: supabase.from(), supabase.rpc(), storage operations, realtime subscriptions และ auth method เองทั้งหมด
สิ่งที่อยู่นอกขอบเขตนี้: fetch call ที่สร้าง manually พร้อม access_token ของ session, HTTP client ใด ๆ ที่กำหนดค่าอิสระจาก Supabase client และ Supabase client instance หลายตัว ถ้าสถาปัตยกรรมของคุณ extract access token เพื่อส่งไปยัง API layer แยก ขอบเขตการป้องกัน race condition จบที่ extraction point นั้น
บทความที่เกี่ยวข้อง
- JWT structure and expiry — Access token และ refresh token ให้บริการจุดประสงค์ที่แตกต่างกัน
- Supabase Row Level Security และ access token — RLS policy ประเมิน JWT claims ของผู้ใช้ที่ request
- React Query และ parallel fetching — useQuery hooks ที่เรียกจาก component หลายตัวบนหน้าเดียวกันจะ execute พร้อมกันตามค่าเริ่มต้น