กับดักโทเคน (token trap) เกิดขึ้นกับเว็บไซต์ที่ใช้งานจริง เมื่อแท็บทั้ง 5 แท็บที่เปิดโดยผู้ใช้คนเดียวพยายามรีเฟรช JWT ที่หมดอายุในเสี้ยววินาทีเดียวกัน ส่งผลให้เกิดการส่งคำขอรีเฟรชซ้ำๆ ไปยัง backend จำนวนมาก และทำให้เซสชันถูกยกเลิกทันที ทุกแท็บจะล็อกเอาต์ผู้ใช้ออก ซึ่งพิสูจน์ให้เห็นว่าวิธีการรีเฟรชโทเคนแบบแท็บเดียวไม่เพียงพออีกต่อไป
ทำไมกับดักโทเคนถึงเป็นเรื่องสำคัญ
แอปพลิเคชันแบบ single-page สมัยใหม่ใช้ access token ที่มีอายุสั้น และ refresh token ที่มีอายุยาว เมื่อ access token หมดอายุ ไคลเอนต์จะส่งคำขอรีเฟรช รับคู่โทเคนใหม่ และลองเรียกคำขอเดิมอีกครั้ง นักพัฒนาส่วนใหญ่มักป้องกันกระบวนการนี้ด้วย flag ในหน่วยความจำ (เช่น isRefreshing = true) หรือ request queue โดยทดสอบในแท็บเดียว แต่ในโลกความเป็นจริง ผู้ใช้มักเปิดหลายแท็บพร้อมกัน เช่น หน้าการตั้งค่า, แดชบอร์ดวิเคราะห์ข้อมูล และหน้าแสดงข้อมูลต่างๆ เมื่อ access token หมดอายุ แต่ละแท็บจะตรวจพบข้อผิดพลาด 401 แยกกัน และแต่ละแท็บจะส่งคำขอรีเฟรชออกมา ซึ่ง backend—โดยเฉพาะเมื่อมีการใช้ระบบ refresh-token rotation—จะมองว่าคำขอที่สองเป็นการส่งซ้ำ (replay) และยกเลิกเซสชันทั้งหมด
การแยกส่วนของ JavaScript ทำให้เกิดปัญหา
แต่ละแท็บของเบราว์เซอร์จะทำงานใน JavaScript context ของตัวเอง ตัวแปร, ตัวจับเวลา (timers) และ flag ในหน่วยความจำ จะไม่สามารถมองเห็นได้จากแท็บอื่น แม้ว่าจะใช้ origin เดียวกันก็ตาม flag ที่ระบุว่า “กำลังอยู่ในระหว่างการรีเฟรช” จะมีอยู่แค่ภายในแท็บที่ตั้งค่ามันไว้เท่านั้น แท็บอื่นๆ ไม่มีทางรู้เลยว่าโทเคนกำลังถูกรีเฟรชที่อื่น ดังนั้นพวกมันจึงเริ่มการเรียกเครือข่าย (network call) ของตัวเอง ปัญหานี้ไม่ใช่บั๊กใน interceptor แต่เป็นข้อจำกัดพื้นฐานของการแยกส่วนสถานะฝั่งไคลเอนต์ (client-side state isolation)
Web Locks API มาช่วยกู้สถานการณ์
เมื่อแท็บหนึ่งถือครอง lock ไว้ แท็บอื่นๆ ที่ขอ lock เดียวกันจะต้องรอจนกว่าจะมีการปล่อย lock นั้น
วิธีการทำงานสำหรับการรีเฟรชโทเคน
- ตรวจพบ 401 – แท็บใดก็ตามที่ได้รับ response ที่ไม่ได้รับอนุญาตจะเรียก
navigator.locks.request('auth_token_refresh_lock', async lock => { … }) - รับสิทธิ์การล็อก – หากไม่มีแท็บอื่นถือครอง lock อยู่ แท็บปัจจุบันจะดำเนินการต่อ มิฉะนั้นจะหยุดรอจนกว่า lock จะว่าง
- รีเฟรชเพียงครั้งเดียว – ผู้ที่ถือครอง lock จะส่งคำขอรีเฟรช เก็บ access token ใหม่และ timestamp ไว้ใน
localStorageจากนั้นจะปล่อย lock โดยอัตโนมัติเมื่อ callback ทำงานเสร็จสิ้น - ข้ามการทำงานที่ซ้ำซ้อน – เมื่อแท็บที่รออยู่ได้รับ lock ในที่สุด มันจะอ่าน timestamp จาก
localStorageหากโทเคนถูกรีเฟรชภายในช่วงเวลาที่กำหนดไว้ (เช่น ในไม่กี่วินาทีที่ผ่านมา) แท็บนั้นจะข้ามการเรียกเครือข่ายและอัปเดตโทเคนในหน่วยความจำจากlocalStorageแทน - จัดการกรณีแอปค้างหรือปิดตัวลง – หากแท็บค้างหรือถูกปิดในขณะที่ถือครอง lock อยู่ เบราว์เซอร์จะปล่อย lock ให้โดยอัตโนมัติ เพื่อให้แท็บอื่นสามารถลองรีเฟรชใหม่ได้
สรุปข้อดี
- ไม่มีการเรียกเครือข่ายที่ซ้ำซ้อน – มีเพียงแท็บแรกเท่านั้นที่ติดต่อกับ backend
- เซสชันไม่ถูกทำลาย – ระบบ refresh-token rotation จะเห็นการใช้งานเพียงครั้งเดียว ทำให้เซสชันยังคงอยู่
- การกู้คืนที่ราบรื่น – การปล่อย lock โดยเบราว์เซอร์ช่วยป้องกันการเกิด deadlock หากแท็บหายไป
รายการตรวจสอบการนำไปใช้งาน (Implementation checklist)
- ครอบ logic การรีเฟรชไว้ใน Axios (หรือ fetch) interceptor ของคุณด้วยการขอ lock
- เก็บโทเคนที่รีเฟรชแล้วและ timestamp ระดับมิลลิวินาทีไว้ใน
localStorage(หรือsessionStorageหากคุณต้องการข้อมูลแยกตามเซสชัน) - เมื่อได้รับอนุญาตให้ใช้ lock ให้เปรียบเทียบ timestamp ที่เก็บไว้กับ
Date.now()หากความแตกต่างต่ำกว่าเกณฑ์ที่คุณกำหนด ให้ดึงโทเคนจาก storage แทนการเรียก backend - ตรวจสอบให้แน่ใจว่า interceptor อัปเดต request headers ด้วยโทเคนที่ดึงมาจาก storage ก่อนที่จะลองเรียก API เดิมอีกครั้ง
- ทดสอบกระบวนการด้วยหลายแท็บ โดยจำลอง network latency เพื่อยืนยันว่ามีคำขอรีเฟรชเพียงคำขอเดียวเท่านั้นที่ส่งไปถึงเซิร์ฟเวอร์
สิ่งที่อาจผิดพลาดได้
สิ่งที่ควรระวังต่อไป
บทสรุป
ให้มองว่าแต่ละแท็บของเบราว์เซอร์เป็นโหนด (node) ในระบบกระจายตัว (distributed system) ขนาดเล็ก การใช้ Web Locks API เพื่อจัดลำดับการรีเฟรช JWT จะช่วยกำจัดคำขอที่ซ้ำซ้อน ปกป้องระบบ refresh-token rotation และช่วยให้ผู้ใช้ยังคงล็อกอินอยู่ได้ในทุกแท็บที่เปิดไว้
