เมื่อต้องจัดการ OAuth ใน Single Page Application (SPA) การจัดเก็บ access token และ refresh token ถือเป็นประเด็นที่ถกเถียงกันมานาน localStorage, sessionStorage และตัวแปรในหน่วยความจำล้วนไม่ปลอดภัยพอต่อ XSS รูปแบบ Backend for Frontend (BFF) คือการออกแบบที่เก็บ token ไว้ที่ฝั่งเซิร์ฟเวอร์แทนที่จะส่งไปยังเบราว์เซอร์ บทความนี้จะรวบรวมกลไกและประเด็นสำคัญในการนำไปใช้งาน
ข้อกำหนดเบื้องต้น
บทความนี้สมมติให้มีสภาพแวดล้อมดังต่อไปนี้:
- การพัฒนาแอปบนเบราว์เซอร์ที่ใช้ OAuth 2.0 และ OpenID Connect
- มี SPA, API ที่ SPA เรียกใช้ และเซิร์ฟเวอร์อนุญาตสิทธิ์ (authorization server)
- SPA และคอมโพเนนต์ฝั่งเซิร์ฟเวอร์ที่รองรับสามารถอยู่บนโดเมนหลักเดียวกัน
- ต้องใช้ HTTPS (จำเป็นสำหรับการออก Secure Cookies)
ความเสี่ยง XSS ในแอปบนเบราว์เซอร์
โค้ดแอปพลิเคชันที่ทำงานในเบราว์เซอร์มีความเสี่ยงต่อทุกสิ่งที่เกิดขึ้นในสภาพแวดล้อมการทำงานของเบราว์เซอร์ XSS ส่งผลกระทบในวงกว้าง และเนื่องจากโค้ดโจมตีทำงานในบริบทเดียวกันกับแอป จึงสามารถดำเนินการต่อไปนี้ได้:
- อ่านค่าที่จัดเก็บใน localStorage หรือ sessionStorage
- อ่านตัวแปรในหน่วยความจำที่ JavaScript เข้าถึงได้
- ดำเนินการเรียก API ทั้งหมดที่แอปสามารถทำได้
- เปลี่ยนพฤติกรรมโดยการเขียนทับฟังก์ชันในตัว (prototype pollution)
XSS มีจุดเข้าหลายจุด เช่น ช่องโหว่ในไลบรารีที่ขึ้นต่อกัน ข้อบกพร่องในการประมวลผลอินพุต/เอาต์พุตของโค้ดของคุณเอง หรือการประนีประนอมของสคริปต์จากบุคคลที่สาม เนื่องจากการป้องกัน XSS อย่างสมบูรณ์นั้นทำได้ยาก นโยบายที่สมเหตุสมผลคือ "จำกัดผลกระทบหากเกิดการบุกรุก"
ตราบใดที่ token ยังถูกวางไว้ในเบราว์เซอร์ โอกาสที่ token จะถูกขโมยผ่าน XSS ก็ยังคงมีอยู่ หากผู้โจมตีใช้ refresh token ที่ถูกขโมยในสภาพแวดล้อมของตนเอง พวกเขาสามารถเรียก API ได้เป็นเวลานานแม้ผู้ใช้จะปิดเว็บไซต์ไปแล้ว แม้ว่าการหมุน token และการหมดเวลาขณะไม่ได้ใช้งาน (idle timeout) จะช่วยลดผลกระทบได้ แต่ก็ไม่ใช่วิธีแก้ปัญหาในเชิงรากฐาน
การออกแบบที่ไม่วาง Token ในเบราว์เซอร์
รูปแบบ BFF จัดให้มีคอมโพเนนต์ฝั่งเซิร์ฟเวอร์ที่ออกแบบมาโดยเฉพาะสำหรับ SPA และรวมศูนย์ความรับผิดชอบของ OAuth client ไว้ที่นั่น SPA ไม่ได้จัดการประมวลผล OAuth โดยตรง แต่ดำเนินการตรวจสอบสิทธิ์และเรียก API ผ่าน BFF
การแบ่งบทบาทหน้าที่มีดังนี้:
- การสื่อสารโปรโตคอล OAuth กับเซิร์ฟเวอร์อนุญาตสิทธิ์: จัดการโดย BFF
- การถือ access token และ refresh token: BFF เท่านั้น
- การคงสถานะการตรวจสอบสิทธิ์ระหว่าง SPA และ BFF: HttpOnly Cookie
- การเรียก API: SPA ส่งคำขอไปยัง BFF และ BFF แปลง Cookie เป็น token แล้วส่งต่อไปยัง API
ในการกำหนดค่านี้ token จะไหลเวียนเฉพาะระหว่าง BFF และ API ที่ BFF เรียกใช้เท่านั้น และไม่ปรากฏให้ JavaScript ของเบราว์เซอร์มองเห็นได้ แม้จะเกิด XSS ผู้โจมตีก็ไม่สามารถดึง token ออกมาและนำไปใช้จากที่อื่นได้ สิ่งที่ผู้โจมตีทำได้คือส่งคำขอไปยัง BFF ภายในขอบเขตของเซสชันที่ผู้ใช้กำลังเปิดอยู่เท่านั้น แม้ว่าผลกระทบนี้จะไม่ใช่เรื่องเล็กน้อย แต่ระยะเวลาและขอบเขตของผลกระทบนั้นถูกจำกัดอย่างมากเมื่อเทียบกับการขโมย token
BFF ทำหน้าที่เป็นสิ่งที่ศัพท์ OAuth เรียกว่า "confidential client" โดยถือ client secret และดำเนินการแลกเปลี่ยน authorization code เป็น token และกระบวนการ refresh ทั้งหมดบนฝั่งเซิร์ฟเวอร์
โฟลว์การตรวจสอบสิทธิ์
โฟลว์การตรวจสอบสิทธิ์โดยทั่วไปมีดังนี้:

- เมื่อ SPA เริ่มต้นการเข้าสู่ระบบ จะส่งคำขอเข้าสู่ระบบไปยัง BFF
- BFF เริ่มต้นโฟลว์ authorization code พร้อม PKCE สร้าง URL เปลี่ยนเส้นทางไปยังเซิร์ฟเวอร์อนุญาตสิทธิ์ และส่งกลับไปยัง SPA
- SPA เปลี่ยนเส้นทางเบราว์เซอร์ไปยัง URL นั้น และผู้ใช้ตรวจสอบสิทธิ์ที่เซิร์ฟเวอร์อนุญาตสิทธิ์
- เซิร์ฟเวอร์อนุญาตสิทธิ์ส่ง authorization code กลับไปยัง URI เปลี่ยนเส้นทางของ BFF
- BFF แลกเปลี่ยน authorization code เป็น token และได้รับ access token และ refresh token
- BFF จัดเก็บ token ไว้ในพื้นที่ปลอดภัยของตนเอง (encrypted cookie, เซิร์ฟเวอร์-side session store ฯลฯ) และส่งเฉพาะ session identifier กลับไปยัง SPA ผ่าน HttpOnly Cookie
- เมื่อ SPA เรียก API จะส่งคำขอผ่าน BFF Cookie จะถูกส่งไปพร้อมกัน และ BFF จะแปลงเป็น access token และส่งต่อไปยัง API ปลายทาง
- หาก access token หมดอายุ BFF จะอัปเดตอย่างเงียบ ๆ โดยใช้ refresh token
จากมุมมองของ SPA สถานะการเข้าสู่ระบบจะคงอยู่โดย Cookie และการเรียก API จะเสร็จสมบูรณ์ด้วยการเรียก fetch ปกติ โค้ดที่จัดการ OAuth token หรือ authorization code โดยตรงไม่มีอยู่ใน SPA
การตั้งค่าความปลอดภัยที่จำเป็น
ต้องตั้งค่าแอตทริบิวต์ต่อไปนี้สำหรับ Cookie ที่ออกโดย BFF:
- HttpOnly: ห้ามการเข้าถึงจาก JavaScript แม้มี XSS ก็ไม่สามารถอ่านเนื้อหา Cookie ได้
- Secure: ส่งผ่าน HTTPS เท่านั้น
- SameSite=Strict: รับประกันว่า Cookie จะไม่ถูกส่งไปกับคำขอจากไซต์อื่น ซึ่งช่วยสกัดกั้นเส้นทางการโจมตี CSRA หลัก แต่การป้องกัน CSRF ยังไม่สมบูรณ์ด้วยวิธีนี้เพียงอย่างเดียว
- คำนำหน้า __Host-: การเพิ่มคำนำหน้า __Host- ในชื่อ Cookie จะทำให้เบราว์เซอร์จำกัด Cookie ไว้เฉพาะโฮสต์ที่ออก และไม่แชร์กับซับโดเมน
หากจัดเก็บ token ใน Cookie ที่เข้ารหัส (client-side session) เนื้อหา Cookie จะถูกเข้ารหัส หากวาง token ในเซิร์ฟเวอร์-side session store (server-side session) Cookie จะมีเฉพาะ session identifier ดังนั้นจึงไม่จำเป็นต้องเข้ารหัส
การป้องกัน CSRF ไม่ควรพึ่งพา SameSite เพียงอย่างเดียว
เนื่องจากใช้การตรวจสอบสิทธิ์แบบใช้ Cookie BFF จึงต้องใช้การป้องกัน CSRF SameSite=Strict เป็นขั้นตอนที่ถูกต้อง แต่ไม่ใช่คำตอบทั้งหมด ต้องระมัดระวังเป็นพิเศษในการกำหนดค่าที่ SPA อยู่บน www.example.com และ BFF อยู่บน api.example.com เนื่องจากการกำหนด SameSite จะกระทำตามไซต์ ไม่ใช่ต้นทาง คำขอจากซับโดเมนอื่นภายใต้ example.com จะถือว่าเป็น "same-site" และ Cookie จะถูกส่งแม้จะใช้ SameSite=Strict ก็ตาม
ดังนั้น ให้เสริมการป้องกัน CSRF ด้วยวิธีใดวิธีหนึ่งต่อไปนี้:
- หาก BFF และ SPA อยู่บนต้นทางที่ต่างกัน ให้ใช้ CORS และการตรวจสอบ Origin header เพื่อป้องกัน
- สำหรับคำขอที่เปลี่ยนสถานะ ให้ตรวจสอบ CSRF token โดยใช้วิธี anti-forgery / double-submit cookie
จำกัด CORS ให้เฉพาะต้นทางที่แน่นอน
ควรอนุญาต CORS สำหรับต้นทางที่แน่นอนของ SPA เท่านั้น สำหรับคำขอที่รับรองความถูกต้อง (credentialed requests) ที่เกี่ยวข้องกับ Cookie เบราว์เซอร์ไม่อนุญาตให้ใช้สัญลักษณ์แทน (*) ใน Access-Control-Allow-Origin ดังนั้น BFF ต้องสะท้อนต้นทางที่แน่นอนของ SPA ในการตอบกลับ การจำกัดต้นทางที่อนุญาตอย่างเคร่งครัดนี้ทำหน้าที่เป็นส่วนหนึ่งของการป้องกัน CSRF
จำเป็นต้องมีการจัดการคีย์สำหรับข้อมูลเซสชันที่ BFF ถืออยู่ โดยเฉพาะข้อมูล token ที่จัดเก็บใน Cookie ที่เข้ารหัส ควรรวมการดำเนินการเพื่อฉีดคีย์อย่างปลอดภัยเป็นส่วนหนึ่งของการตั้งค่า BFF และหมุนเวียนคีย์เป็นประจำ
โปรดทราบว่าการวาง SPA และ BFF บนโดเมนหลักเดียวกันนั้นเป็นไปตามเงื่อนไขเพื่อให้ Same-Site Cookies ทำหน้าที่เป็น first-party Cookies
วิวัฒนาการ: BFF ที่ขับเคลื่อนด้วย API
หากสร้าง BFF เหมือนเว็บแอปแบบดั้งเดิม การเรนเดอร์ฝั่งเซิร์ฟเวอร์ของ BFF จะเข้ามาเกี่ยวข้องกับการเปลี่ยนหน้าของ SPA BFF ที่ขับเคลื่อนด้วย API (API-driven BFF) จะช่วยลดผลกระทบนี้
บทบาทจะแบ่งออกเป็นสองส่วน:
- OAuth Agent: API ที่รับผิดชอบการประมวลผลโปรโตคอล OAuth ถูกเรียกจาก SPA ผ่าน JSON
- OAuth Proxy: ทำหน้าที่เป็นปลั๊กอินของ API gateway ดึง token จาก Cookie และส่งต่อไปยัง API ปลายทาง
ในการกำหนดค่านี้ SPA เพียงเรียก OAuth Agent เป็น REST API ปกติ ประสบการณ์การพัฒนา frontend ของ SPA สามารถคงไว้ได้เกือบเหมือนก่อนที่จะนำ BFF มาใช้
ข้อควรพิจารณาในการนำไปใช้
เมื่อนำรูปแบบ BFF ไปใช้ ควรพิจารณาสิ่งต่อไปนี้:
- ส่วนประกอบทางสถาปัตยกรรมที่เพิ่มขึ้นทำให้ต้นทุนการพัฒนาและการดำเนินงานสูงขึ้น
- SPA และ BFF ต้องวางบนโดเมนหลักเดียวกัน
- การกำหนดค่าที่เพียงแค่ส่งต่อ Authorization header ผ่าน reverse proxy ไม่ใช่ BFF สาระสำคัญของ BFF คือการถือ token ในฐานะ confidential client
- PKCE และ BFF ถูกใช้ร่วมกัน ไม่ใช่ใช้แทนกัน
- BFF ต้องตรวจสอบโฮสต์ปลายทางก่อนส่งต่อเพื่อป้องกันไม่ให้ token ถูกเปิดเผยต่อโฮสต์ที่ไม่พึงประสงค์
- การออกแบบการออกจากระบบ (logout) มีความซับซ้อน เนื่องจากต้องเชื่อมโยงเซสชันของ SPA, BFF และเซิร์ฟเวอร์อนุญาตสิทธิ์เข้าด้วยกัน
สรุป
วิธีที่ใช้ได้จริงในการจัดการ token อย่างปลอดภัยในแอปบนเบราว์เซอร์คือการไม่วาง token ไว้ในเบราว์เซอร์ รูปแบบ BFF ย้ายความรับผิดชอบของ OAuth client ไปยังฝั่งเซิร์ฟเวอร์ และส่ง HttpOnly Cookies ไปยังเบราว์เซอร์เท่านั้น ซึ่งช่วยป้องกันการขโมย token แม้จะเกิด XSS โดยการแบ่งบทบาทออกเป็น OAuth Agent และ OAuth Proxy ความปลอดภัยสามารถเพิ่มขึ้นได้ในขณะที่ยังคงรักษาประสบการณ์การพัฒนา SPA เอาไว้





