ป้องกันภาวะแข่งขันขณะต่ออายุโทเคนในแอปพลิเคชันหน้าเดียว
ผู้ใช้หลุดจากระบบโดยไม่มีคำอธิบาย โทเคนเซสชันใน localStorage ดูถูกต้อง คำขอล่าสุดสำเร็จเมื่อไม่กี่วินาทีก่อน และอาการเกิดไม่สม่ำเสมอจนหาสาเหตุยาก แต่บ่อยครั้งเกิดตอนโหลดหน้าที่เรียก API หลายคำขอพร้อมกัน นี่คือข้อผิดพลาดที่ชวนสับสนในแอปพลิเคชันหน้าเดียวที่ใช้งานจริง สาเหตุหนึ่งที่พบบ่อยคือภาวะแข่งขันในการเข้าถึงทรัพยากรระหว่างต่ออายุโทเคน (token refresh race condition)
กลไกนี้เข้าใจได้ง่ายเมื่อระบุสาเหตุแล้ว JWT มีวันหมดอายุ เมื่อโทเคนหมดระหว่างเซสชัน พฤติกรรมที่ถูกต้องคือแลกโทเคนต่ออายุเป็นโทเคนเข้าถึงใหม่ แล้วส่งคำขอเดิมซ้ำโดยผู้ใช้ไม่ต้องทำอะไร แต่ในทางปฏิบัติ แอป React แทบไม่ส่งคำขอทีละรายการ หน้าแดชบอร์ดอาจดึงข้อมูลผู้ใช้ สรุปบัญชี จำนวนการแจ้งเตือน และกิจกรรมล่าสุดพร้อมกันเมื่อคอมโพเนนต์เริ่มทำงาน หากโทเคนเข้าถึงหมดอายุในช่วงนั้น ทุกคำขอจะได้รับ 401 ต่างคนต่างตรวจพบโทเคนหมดอายุ และต่างเรียกปลายทางต่ออายุ คำขอแรกสำเร็จและใช้โทเคนต่ออายุเก่าไปแล้ว คำขอที่สองใช้โทเคนเดิมซึ่งถูกทำให้ใช้ไม่ได้แล้วจึงล้มเหลว ผู้ใช้จึงหลุดจากระบบ
เหตุใดจึงไม่ค่อยพบระหว่างพัฒนา
ภาวะแข่งขันนี้ต้องอาศัยเหตุการณ์ที่เกิดพร้อมกันโดยเฉพาะ: คำขอหลายรายการทำงานพร้อมกันจริง ๆ และโทเคนหมดอายุในจังหวะที่ไม่เหมาะพอดี สภาพแวดล้อมพัฒนามักกำหนดอายุโทเคนไว้ยาว ความหน่วงบน localhost แทบเป็นศูนย์ คำขอจึงเสร็จเร็วและไม่ค่อยทับซ้อนกันทั้งหมด อีกทั้งนักพัฒนาส่วนใหญ่ทดสอบทีละคุณสมบัติ แทนที่จะโหลดหน้าที่มีข้อมูลจำนวนมาก ข้อผิดพลาดนี้จึงซ่อนอยู่ระหว่างพัฒนาและปรากฏในสภาพแวดล้อมใช้งานจริงตามรูปแบบการใช้งานจริง โดยมักกระทบผู้ใช้ที่ใช้งานต่อเนื่องนานจนโทเคนแรกหมดอายุระหว่างเซสชัน
หากต้องการทำให้อาการเกิดซ้ำโดยตั้งใจ ต้องลดอายุโทเคนแล้วเปิดหน้าที่ดึงข้อมูลแบบคู่ขนาน ใน Supabase การตั้งค่า jwt_expiry ของโครงการควบคุมอายุโทเคนเข้าถึง ตั้งเป็นหกสิบวินาที เปิดแดชบอร์ดที่มีหลายคิวรี แล้วรอ โดยทั่วไปภาวะแข่งขันจะปรากฏภายในไม่กี่นาทีแรกของการใช้งานต่อเนื่อง แต่ระยะเวลาก่อนเกิดอาการอาจแตกต่างกัน
อาการนี้เกิดอย่างไรกับการยืนยันตัวตนของ Supabase
ไคลเอนต์ @supabase/supabase-js v2 ของ Supabase จัดการต่ออายุโทเคนภายใน และมีตรรกะจัดลำดับให้ทำงานทีละครั้ง โดยเก็บ promise ของการต่ออายุที่กำลังทำงานเพียงตัวเดียว และจัดคิวผู้เรียกพร้อมกันให้รอ promise นั้น สำหรับแอปที่ใช้ไคลเอนต์ Supabase กับทุก API การจัดการในตัวนี้มักเพียงพอ แต่แอปที่ใช้งานจริงส่วนใหญ่มักต้องส่งคำขอที่ยืนยันตัวตนแล้วนอกไคลเอนต์ Supabase เช่น เรียก API ของ backend เอง เรียกฟังก์ชัน edge ผ่าน fetch หรือเชื่อมต่อบริการภายนอกที่ต้องส่งโทเคนเข้าถึงในส่วนหัว ทันทีที่ดึงโทเคนเข้าถึงจากเซสชันไปใช้โดยตรงในชั้น HTTP แยกต่างหาก ก็ออกจากการป้องกันของการจัดลำดับภายในไคลเอนต์
ความผิดพลาดอีกแบบเกิดเมื่อไม่ได้เริ่มต้นไคลเอนต์ Supabase ให้เป็นอินสแตนซ์เดียวอย่างถูกต้อง หากการประเมินโมดูลสร้างไคลเอนต์หลายอินสแตนซ์ ซึ่งอาจเกิดในบางบริบทของ SSR หรือสภาพแวดล้อมทดสอบที่ประเมินโมดูลใหม่ แต่ละอินสแตนซ์จะไม่ใช้สถานะต่ออายุภายในร่วมกัน ไคลเอนต์สองอินสแตนซ์จะต่างพยายามต่ออายุเมื่อคำขอของตนได้รับ 401 ภาวะแข่งขันจึงกลับมาแม้จะมีการจัดลำดับในตัว
รูปแบบ promise ร่วมเพียงตัวเดียว
วิธีที่กระชับและเหมาะสมคือเก็บการต่ออายุที่กำลังทำงานไว้ใน promise แล้วคืน promise เดิมให้ผู้เรียกทุกคนจนกว่าจะสำเร็จ แทนที่จะให้แต่ละคนเริ่มต่ออายุใหม่แยกกัน ทุกคนรอการทำงานร่วมครั้งเดียว โค้ดมีขนาดกะทัดรัดดังนี้:
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;
}
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
// 30-second buffer avoids tokens that are valid now but expire before the request completes
return payload.exp * 1000 < Date.now() + 30_000;
} catch {
return true;
}
}
ส่วน finally คือรายละเอียดสำคัญ เพราะล้างการอ้างอิง promise ร่วมทั้งเมื่อสำเร็จและถูกปฏิเสธ เพื่อให้รอบต่ออายุถัดไปเริ่มใหม่ได้ หากไม่ล้าง promise ที่ถูกปฏิเสธจะขวางการต่ออายุครั้งถัดไปตลอดอายุโมดูล การเผื่อเวลา 30 วินาทีในการตรวจวันหมดอายุแก้กรณีละเอียดอ่อน: โทเคนที่ผ่านการตรวจ isTokenExpired แต่หมดอายุในไม่กี่ร้อยมิลลิวินาทีก่อนคำขอถึงเซิร์ฟเวอร์จะได้รับ 401 ทำให้เริ่มรอบต่ออายุอีกครั้งโดยไม่จำเป็น ฟังก์ชันช่วยนี้มีข้อควรระวังสองประการ: ส่วนต่าง ๆ ของ JWT เข้ารหัสแบบ base64url แต่ atob รองรับเฉพาะ base64 มาตรฐาน จึงต้องแปลง - และ _ เป็น + และ / ก่อนถอดรหัส มิฉะนั้นโทเคนที่ถูกต้องบางรายการจะถูกมองว่าหมดอายุทุกครั้งที่เรียกใช้ อีกประการคือโทเคนที่ไม่มี claim exp จะถูกมองว่ายังไม่หมดอายุในการเปรียบเทียบ จึงต้องปฏิเสธโทเคนที่ไม่มี exp อย่างชัดเจน
แนวทางจัดคิวผ่าน interceptor
สำหรับแอปที่ใช้ Axios รูปแบบมาตรฐานคือนำตรรกะจัดลำดับเดียวกันไปไว้ในตัวดักการตอบกลับ (response interceptor) แนวทางนี้จัดการส่งคำขอซ้ำอัตโนมัติ โดยนำคำขอที่ล้มเหลวเข้าคิว รอให้ต่ออายุเสร็จ แล้วระบายคิวโดยใส่โทเคนใหม่ในการตั้งค่าของคำขอที่รออยู่แต่ละรายการก่อนส่งซ้ำ:
import axios, { type AxiosError, type AxiosRequestConfig } from "axios";
let isRefreshing = false;
let pendingQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function drainQueue(error: unknown, token: string | null): void {
pendingQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
pendingQueue = [];
}
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as AxiosRequestConfig & { _retry?: boolean };
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// Queue this request — it will be retried once the in-flight refresh completes
return new Promise<string>((resolve, reject) => {
pendingQueue.push({ resolve, reject });
}).then((token) => {
original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
return api(original);
});
}
original._retry = true;
isRefreshing = true;
try {
const { data, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError || !data.session) throw refreshError ?? new Error("Refresh failed");
const newToken = data.session.access_token;
drainQueue(null, newToken);
original.headers = { ...original.headers, Authorization: `Bearer ${newToken}` };
return api(original);
} catch (err) {
drainQueue(err, null);
// Clear the local Supabase session so the app reaches a clean unauthenticated state
await supabase.auth.signOut();
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
);
แฟล็ก _retry ในคำขอที่กระตุ้นให้เริ่มต่ออายุป้องกันการวนซ้ำไม่สิ้นสุด หากคำขอที่ส่งซ้ำยังได้รับ 401 เช่น โทเคนใหม่เองใช้ไม่ได้ด้วยเหตุใดก็ตาม interceptor จะส่งข้อผิดพลาดต่อ แทนที่จะเริ่มต่ออายุอีก ในตัวอย่างนี้ คำขอที่เข้าคิวระหว่างที่กำลังต่ออายุจะไม่ได้รับแฟล็กนี้ ให้ตั้งค่า original._retry = true ในสาขาที่จัดคำขอเข้าคิวด้วย หากไม่ต้องการให้คำขอที่ส่งซ้ำแล้วล้มเหลวอีกเริ่มการต่ออายุรอบใหม่ การเรียก signOut() อย่างชัดเจนเมื่อต่ออายุล้มเหลวทำให้สถานะเซสชันของ Supabase ในเครื่องถูกล้าง แอปจึงเข้าสู่สถานะที่ยังไม่ได้ยืนยันตัวตนอย่างชัดเจน แทนที่จะค้างอยู่ในสภาพที่ส่วนติดต่อผู้ใช้เชื่อว่าผู้ใช้เข้าสู่ระบบแล้วแต่ทุกคำขอล้มเหลว
การซิงค์ React Context ให้ตรงกัน
ทั้งสองรูปแบบข้างต้นจัดการโทเคนในชั้น HTTP แต่แอป React มักต้องมีเซสชันปัจจุบันอยู่ใน context ด้วย เพื่อแสดงผลตามเงื่อนไข ส่งรายละเอียดผู้ใช้ให้คอมโพเนนต์ลูก หรือให้คำขอที่ไม่ได้ใช้ Axios อ่านโทเคนโดยตรง ปัญหาคือการเก็บโทเคนในสถานะ React แล้วซิงค์ให้ตรงกับ interceptor สร้างแหล่งข้อมูลหลักสองแห่งที่อาจไม่ตรงกันเมื่อแสดงผลใหม่
แนวทางที่ชัดเจนกว่าคือเก็บโทเคนหลักไว้ใน ref ที่ interceptor เข้าถึงได้แบบซิงโครนัส ส่วนการติดตามการเปลี่ยนแปลงของ React จัดการสถานะส่วนติดต่อผู้ใช้แยกต่างหาก:
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
tokenRef.current = data.session?.access_token ?? null;
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, newSession) => {
setSession(newSession);
tokenRef.current = newSession?.access_token ?? null;
}
);
return () => subscription.unsubscribe();
}, []);
// Pass tokenRef to your interceptor setup so it reads the current token
// synchronously rather than going async through state
useEffect(() => {
setupInterceptors(tokenRef);
}, []);
return <AuthContext.Provider value={{ session }}>{children}</AuthContext.Provider>;
}
การใช้ ref แทนสถานะทำให้ interceptor อ่านโทเคนปัจจุบันได้แบบซิงโครนัส โดยไม่กระตุ้นการแสดงผลใหม่ และไม่เกิดปัญหา closure เก็บค่าเก่าที่เกิดเมื่อฟังก์ชันเรียกกลับจับค่าสถานะเฉพาะตอนสร้างฟังก์ชัน การติดตามผ่าน onAuthStateChange ทำให้ทั้ง ref และสถานะ React ตรงกันเมื่อเซสชันเปลี่ยน effect ตัวที่สองในตัวอย่างไม่มีการล้างการทำงาน ให้ setupInterceptors คืนฟังก์ชันที่ถอด interceptor ออก (สำหรับ Axios ใช้ interceptors.response.eject(id)) แล้วคืนฟังก์ชันนั้นจาก effect มิฉะนั้น React Strict Mode และการเมานต์ใหม่จะทำให้ลงทะเบียนซ้ำสองครั้ง
สิ่งที่ Supabase จัดการและขอบเขตที่ไม่ครอบคลุม
ควรระบุขอบเขตการป้องกันของการจัดลำดับภายในไคลเอนต์ Supabase ให้ชัดเจน การป้องกันนี้ครอบคลุมการเรียกผ่านเมธอดของไคลเอนต์เอง ได้แก่ supabase.from(), supabase.rpc() การดำเนินการกับพื้นที่จัดเก็บ การติดตามข้อมูลแบบเรียลไทม์ และเมธอดยืนยันตัวตน ไคลเอนต์เก็บ promise ต่ออายุเพียงตัวเดียวภายในและจัดคิวผู้เรียกพร้อมกัน ดังนั้นเมื่อเรียก supabase.auth.getSession() พร้อมกันจากหลายจุด ทุกจุดจะได้รับเซสชันที่ต่ออายุแล้วอย่างถูกต้องเมื่อการต่ออายุครั้งเดียวที่กำลังทำงานเสร็จ
สิ่งที่อยู่นอกการป้องกันนี้คือคำขอ fetch ที่ยืนยันตัวตนโดยสร้างเองด้วย access_token ของเซสชัน ไคลเอนต์ HTTP ที่ตั้งค่าแยกจากไคลเอนต์ Supabase และไคลเอนต์ Supabase หลายอินสแตนซ์ตามที่กล่าวข้างต้น หากสถาปัตยกรรมดึงโทเคนเข้าถึงไปส่งให้ชั้น API แยกต่างหาก ขอบเขตการป้องกันภาวะแข่งขันจะสิ้นสุดที่จุดดึงโทเคนนั้น นี่คือจุดที่ต้องใช้รูปแบบข้างต้น
พฤติกรรมข้ามแท็บ
ภาวะแข่งขันนี้มีรูปแบบที่เกิดข้ามแท็บเบราว์เซอร์ หากผู้ใช้เปิดแอปในสองแท็บและทั้งคู่ตรวจพบโทเคนหมดอายุพร้อมกัน ระบบจัดการเซสชันของแต่ละแท็บจะพยายามต่ออายุแยกกัน ระบบยืนยันตัวตนส่วนใหญ่จัดการผ่านเหตุการณ์ localStorage: แท็บหนึ่งเขียนโทเคนใหม่ลงพื้นที่จัดเก็บ อีกแท็บตรวจพบเหตุการณ์ storage แล้วอัปเดตสถานะเซสชันของตน แทนที่จะเริ่มต่ออายุแข่งกัน ในเบราว์เซอร์ supabase-js v2 จะกระจายเหตุการณ์การยืนยันตัวตนไปยังแท็บอื่นที่ใช้คีย์พื้นที่จัดเก็บเดียวกัน (ผ่าน BroadcastChannel เมื่อบันทึกเซสชันแบบถาวร) จึงทำให้ onAuthStateChange ทำงานในแท็บเหล่านั้นด้วย กลไกนี้ไม่เกี่ยวกับ detectSessionInUrl ซึ่งควบคุมเพียงว่าไคลเอนต์จะอ่านข้อมูลการอนุญาต OAuth จาก URL ของหน้าหรือไม่
แต่ในทางปฏิบัติ วิธีนี้ใช้ได้ก็ต่อเมื่อทุกแท็บที่ใช้งานเริ่มต้นไคลเอนต์ Supabase อย่างถูกต้องและติดตามการเปลี่ยนแปลงสถานะยืนยันตัวตน แอปที่เริ่มต้นไคลเอนต์เมื่อจำเป็น หรือแคชเซสชันตอนคอมโพเนนต์เริ่มทำงานโดยไม่ติดตามการเปลี่ยนแปลงต่อเนื่องผ่าน onAuthStateChange จะพลาดการอัปเดตข้ามแท็บ แท็บที่สองจะพยายามต่ออายุด้วยโทเคนต่ออายุที่ถูกใช้ไปแล้ว ผู้ใช้จึงหลุดจากระบบในแท็บนั้น วิธีแก้เหมือนเดิม: ให้ทุกแท็บติดตาม onAuthStateChange ตั้งแต่เริ่มต้น และไม่แคชโทเคนเซสชันในสถานะระดับโมดูลที่คงอยู่ระหว่างการเปลี่ยนเส้นทาง
การสังเกตอาการในสภาพแวดล้อมใช้งานจริง
ภาวะแข่งขันระหว่างต่ออายุโทเคนไม่ได้แสดงตัวชัดเจน อาจปรากฏในระบบติดตามข้อผิดพลาดเป็น 401 ที่เกิดเป็นครั้งคราวบนปลายทางที่ควรผ่านการยืนยันตัวตน ในข้อมูลวิเคราะห์เป็นการสิ้นสุดเซสชันโดยไม่คาดคิด หรือในรายงานจากผู้ใช้ว่าอยู่ ๆ ก็หลุดจากระบบ ความล้มเหลวแต่ละครั้งดูเหมือนปัญหายืนยันตัวตนเฉพาะครั้ง ไม่ใช่ปัญหาการทำงานพร้อมกันที่เป็นระบบ นี่จึงเป็นเหตุผลที่ทีมมักเสียเวลาตรวจการตั้งค่าอายุโทเคน คุกกี้ หรือสภาพเครือข่าย ก่อนพบสาเหตุจริง
เงื่อนไขที่ทำให้เกิดปัญหาเฉพาะเจาะจง: คำขอคู่ขนานร่วมกับเหตุการณ์โทเคนหมดอายุ ทุกหน้าที่โหลดข้อมูลหลายแหล่งพร้อมกันมีโอกาสพบปัญหา หน้าแดชบอร์ด หน้าฟีด หรือโครงสร้างคอมโพเนนต์ที่คอมโพเนนต์ลูกหลายตัวต่างเรียก useEffect เพื่อดึงข้อมูลของตน จะสร้างรูปแบบคำขอคู่ขนาน ในแอป React Query หรือ SWR ทุกหน้าที่เริ่มใช้หลายคิวรีพร้อมกันก็สร้างเงื่อนไขเดียวกัน เมื่อรู้ว่าต้องสังเกตอะไร วิธีแก้ตรงไปตรงมา แต่ต้องมองปัญหาเป็นเรื่องการทำงานพร้อมกัน ไม่ใช่การตั้งค่าการยืนยันตัวตน ทั้งการจัดคิวผ่าน interceptor และรูปแบบ promise ร่วมเพียงตัวเดียวเป็นแนวทางที่ใช้กันแพร่หลาย เลือกใช้วิธีใดก็ได้ เงื่อนไขสำคัญที่ต้องรักษาคือมีการต่ออายุที่กำลังทำงานได้ไม่เกินหนึ่งครั้งในเวลาใดเวลาหนึ่ง และผู้เรียกทุกคนรอผลนั้นแทนที่จะเริ่มต่ออายุแข่งกัน
บทความที่เกี่ยวข้อง
- โครงสร้างและวันหมดอายุของ JWT — โทเคนเข้าถึงและโทเคนต่ออายุมีหน้าที่ต่างกัน โทเคนเข้าถึงมีอายุสั้นและไม่เก็บสถานะ ส่วนโทเคนต่ออายุมีอายุยาว โดยทั่วไปใช้ได้ครั้งเดียวภายใต้นโยบายหมุนเวียนโทเคน และเก็บไว้ฝั่งเซิร์ฟเวอร์ ตัวอย่างเช่น Supabase อนุญาตให้ใช้ซ้ำได้เฉพาะภายในช่วงเวลาสั้น ๆ (ค่าเริ่มต้นคือ 10 วินาที) และจะเพิกถอนเซสชันเมื่อตรวจพบการใช้ซ้ำนอกช่วงเวลานั้น การเข้าใจความแตกต่างช่วยกำหนดว่าควรให้อายุสั้นเพียงใด และจะเกิดอะไรขึ้นเมื่อต่ออายุล้มเหลว
- Row Level Security ของ Supabase กับโทเคนเข้าถึง — นโยบาย RLS ประเมินข้อมูล claims ใน JWT ของผู้ส่งคำขอ Supabase ปฏิเสธโทเคนที่หมดอายุด้วย 401 แต่หากภาวะแข่งขันทำให้ส่งคำขอโดยไม่มีโทเคน คำขอนั้นจะทำงานด้วยบทบาท
anonซึ่งโดยทั่วไป RLS จะส่งผลลัพธ์ว่างหรือข้อผิดพลาดด้านสิทธิ์กลับมาแทน 401 จึงอาจทำให้ระบุสาเหตุจริงได้ยากขึ้น - React Query กับการดึงข้อมูลแบบคู่ขนาน — ฮุก
useQueryของ React Query ที่เรียกจากหลายคอมโพเนนต์ในหน้าเดียวกันจะทำงานพร้อมกันโดยค่าเริ่มต้น การตั้งค่าไคลเอนต์คิวรีร่วมให้ใช้queryFnที่กำหนดเองและเรียกgetValidAccessToken()ทำให้ทุกคิวรีผ่านชั้นจัดลำดับการต่ออายุเดียวกัน - Service worker กับการแคชโทเคน — แอปที่ใช้ service worker แคชการตอบกลับจาก API เพิ่มจุดที่สามซึ่งต้องรักษาสถานะยืนยันตัวตนให้ตรงกัน Service worker ที่แคชคำขอพร้อมส่วนหัวการยืนยันตัวตนเก่าอาจส่งการตอบกลับที่ไม่เป็นปัจจุบันหลังต่ออายุโทเคน ทำให้ข้อมูลไม่สอดคล้องกันในรายละเอียดที่โยงสาเหตุกลับไปยังการจัดการยืนยันตัวตนได้ยาก