OPTIONS เป็น HTTP method ตัวหนึ่ง
OPTIONS อยู่ในมาตรฐาน HTTP มานานแล้ว ใช้ถาม server ว่า URL นี้รองรับอะไรบ้าง (MDN) server ตอบกลับมาโดยไม่ต้องทำงานจริง ไม่อ่าน ไม่แก้ข้อมูล
curl -i -X OPTIONS https://example.com/api/products
HTTP/1.1 204 No Content
Allow: GET, HEAD, POST, OPTIONS
ในงานจริงแทบไม่มีใครเขียนโค้ดยิง OPTIONS เอง ที่เราเห็นบ่อย ๆ ใน DevTools หรือใน access log ของ nginx เกือบทั้งหมดเป็นเบราว์เซอร์ส่งเองตอนทำ CORS preflight
เบราว์เซอร์ไม่ให้หน้าเว็บอ่านข้อมูลข้ามโดเมนเอง
เริ่มจากกฎของเบราว์เซอร์ที่ชื่อ same-origin policy โค้ด JavaScript บนหน้าเว็บอ่าน response ได้เฉพาะจาก origin เดียวกับหน้าเว็บ origin คือสามอย่างรวมกัน: scheme, โดเมน และ port
| หน้าเว็บอยู่ที่ | ยิงไปที่ | origin เดียวกัน? |
|---|---|---|
https://shop.test |
https://shop.test/api/products |
ใช่ |
https://shop.test |
https://api.shop.test/products |
ไม่ โดเมนต่าง |
http://localhost:5173 |
http://localhost:8000/api |
ไม่ port ต่าง |
http://shop.test |
https://shop.test/api |
ไม่ scheme ต่าง |
กฎนี้กันไม่ให้เว็บแปลกหน้าที่เราเผลอเปิด แอบยิงไปอ่านข้อมูลจากระบบที่เรา login ค้างไว้ในอีกแท็บ แต่เว็บสมัยนี้แยก frontend กับ API คนละโดเมนเป็นเรื่องปกติ จึงต้องมีทางให้ server บอกได้ว่ายอมให้ origin ไหนเข้ามา ทางนั้นคือ CORS (Cross-Origin Resource Sharing) server ตอบ header Access-Control-Allow-Origin มา เบราว์เซอร์เห็นแล้วถึงยอมส่ง response ให้ JavaScript อ่าน
บางคำขอต้องถามก่อน บางคำขอไม่ต้อง
CORS แบ่งคำขอข้ามโดเมนเป็นสองแบบ
คำขอธรรมดา (simple request) ส่งไปเลย ไม่ต้องถาม ต้องครบทุกข้อนี้ (MDN)
- method เป็น
GET,HEADหรือPOST - ไม่ใส่ header เพิ่มเอง นอกจากกลุ่มพื้นฐานอย่าง
Accept,Accept-Language,Content-Language,Content-Type Content-Typeเป็นapplication/x-www-form-urlencoded,multipart/form-dataหรือtext/plain
ทั้งหมดนี้คือสิ่งที่ฟอร์ม HTML ส่งได้อยู่แล้วตั้งแต่ก่อนมี CORS server ทุกตัวจึงต้องรับมือกับคำขอแบบนี้ได้อยู่แล้ว เบราว์เซอร์เลยปล่อยไปก่อน แล้วค่อยดู header ใน response ว่าจะให้ JavaScript อ่านไหม
คำขออื่นทั้งหมด เบราว์เซอร์ต้องส่ง preflight ไปถามก่อน เช่น
PUT,PATCH,DELETEหรือ method ใหม่อย่างQUERYPOSTที่ส่ง JSON (Content-Type: application/json)- คำขอที่มี
Authorization: Bearer …หรือ header เอง เช่นX-Requested-With
สังเกตว่าแค่ส่ง JSON หรือแนบ token ก็ต้อง preflight แล้ว API ที่หน้าเว็บสมัยนี้เรียกจึงแทบทุกคำขอต้องผ่าน OPTIONS ก่อน
preflight บอกว่าจะขออะไร แล้ว server ตอบว่าอนุญาตแค่ไหน
สมมติหน้าเว็บที่ https://shop.test จะยิง PUT ส่ง JSON พร้อม token ไปที่ https://api.shop.test
sequenceDiagram
participant B as เบราว์เซอร์
participant S as api.shop.test
B->>S: OPTIONS /orders/15 (ขอ PUT ได้ไหม)
S-->>B: 204 อนุญาต origin, method, header
B->>S: PUT /orders/15 (คำขอจริง)
S-->>B: 200 พร้อม Access-Control-Allow-Originเบราว์เซอร์ส่งสองคำขอ โค้ดเราเขียน fetch แค่ครั้งเดียว
เบราว์เซอร์ส่ง OPTIONS ไปก่อน ไม่มี body มีแค่ header สามตัวที่บอกว่าจะขออะไร
OPTIONS /orders/15 HTTP/1.1
Host: api.shop.test
Origin: https://shop.test
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.test
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 7200
เบราว์เซอร์เทียบคำตอบกับที่ขอไปทีละข้อ origin ตรงไหม method อยู่ในรายการไหม header ครบไหม ถ้าผ่านหมดก็ส่ง PUT ตัวจริงตามไป ถ้าขาดข้อเดียว เบราว์เซอร์หยุดตรงนั้น คำขอจริงไม่ถึง server เลย แล้วขึ้น error ใน console ประมาณนี้
Access to fetch at 'https://api.shop.test/orders/15' from origin 'https://shop.test'
has been blocked by CORS policy: Response to preflight request doesn't pass
access control check
Access-Control-Max-Age บอกให้เบราว์เซอร์จำคำตอบไว้กี่วินาที ช่วงนั้นยิงคำขอแบบเดิมไป URL เดิมก็ไม่ต้องถามซ้ำ Chrome จำได้สูงสุด 2 ชั่วโมง Firefox สูงสุด 24 ชั่วโมง (MDN)
เบราว์เซอร์เป็นคนตรวจ CORS ส่วน server แค่ตอบ header
ตรงนี้สับสนง่าย server แค่ตอบ header ตามที่ตั้งไว้ จะยอมหรือไม่ยอมเป็นเรื่องของเบราว์เซอร์ทั้งหมด
- curl, Postman และ
Http::ใน Laravel ไม่สน CORS ยิงตรงได้หมด เพราะไม่มีหน้าเว็บของใครที่ต้องปกป้อง ถ้ายิงด้วย Postman ได้แต่หน้าเว็บยิงไม่ได้ แทบแน่นอนว่าเป็น CORS - CORS ไม่ได้กัน API จากคนนอก ใครก็ยิงด้วย curl ได้อยู่ดี ป้องกัน API ยังต้องใช้ auth, token และ rate limit ตามปกติ
- คำขอธรรมดาถึง server ทุกครั้ง ต่อให้ origin ไม่ได้รับอนุญาต server ก็ทำงานไปแล้ว เบราว์เซอร์แค่ไม่ให้ JavaScript อ่านผล ฟอร์ม POST จึงยังต้องมี CSRF token เหมือนเดิม
Laravel ตอบ preflight ให้แล้ว
Laravel ใส่ middleware HandleCors ไว้ใน global middleware ตั้งแต่ติดตั้ง ถ้าคำขอเป็น OPTIONS ที่มี Access-Control-Request-Method และ path ตรงกับที่ตั้งไว้ middleware จะตอบ 204 กลับไปเลย ไม่เข้า route ไม่ผ่าน auth ไม่ถึง controller เราจึงไม่ต้องเขียน route OPTIONS เอง
ค่าตั้งต้นอยู่ใน framework ถ้าอยากแก้ให้ publish ออกมาก่อน
php artisan config:publish cors
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['https://shop.test'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 7200,
'supports_credentials' => false,
];
ค่าที่ควรรู้
pathsคือ path ที่ middleware ดูแล ค่าตั้งต้นครอบแค่api/*ถ้าหน้าเว็บอีกโดเมนยิงมาที่ route ในweb.phpเช่น/products/searchต้องเพิ่มเข้าไป ไม่อย่างนั้น preflight จะหลุดไปถึง router ซึ่งตอบกลับโดยไม่มี header CORS แล้วเบราว์เซอร์ก็บล็อกallowed_originsค่าตั้งต้นเป็น['*']คือรับทุกโดเมน API สาธารณะใช้ได้ แต่ถ้า API มีไว้ให้ frontend ของเราเท่านั้น ใส่โดเมนจริงดีกว่า โดเมนที่เปลี่ยนตามลูกค้าใช้allowed_origins_patternsเป็น regex เช่น'#^https://[a-z0-9-]+\.shop\.test$#'max_ageค่าตั้งต้นเป็น0เบราว์เซอร์จึงส่งOPTIONSก่อนทุกคำขอ ตั้งเป็น7200จะลดคำขอลงเกือบครึ่ง โดยเฉพาะหน้า dashboard ที่ยิง API ถี่ ๆsupports_credentialsต้องเป็นtrueถ้าหน้าเว็บส่ง cookie ไปด้วย เช่นใช้ Sanctum แบบ SPA (fetchใส่credentials: 'include') พอเปิดค่านี้ เบราว์เซอร์จะไม่ยอมรับAccess-Control-Allow-Origin: *แล้ว Laravel จะตอบ origin ที่ขอมาแทน แต่ในallowed_originsเราควรใส่โดเมนจริงอยู่ดี
preflight ไม่ผ่าน ให้เริ่มดูที่แท็บ Network
เปิด DevTools แท็บ Network แล้วหาคำขอ OPTIONS ที่อยู่ก่อนคำขอจริง (Chrome ติดป้าย type เป็น preflight) ดูทั้ง status และ response header
- ไม่มี
Access-Control-Allow-Originเลย path ไม่อยู่ในpathsหรือ origin ไม่อยู่ในallowed_originsเช็คให้ตรงทั้ง scheme และ port ด้วยhttp://localhost:5173กับhttp://127.0.0.1:5173ถือเป็นคนละ origin 401หรือ403มีอะไรบางอย่างตอบOPTIONSก่อนถึงHandleCorsเช่น middleware ที่เราใส่ไว้ใน global ก่อนหน้า หรือ proxy อีกชั้นที่ขอ auth เบราว์เซอร์ไม่แนบ token ไปกับ preflight จึงไม่ผ่าน404หรือ405จาก nginx มี config ใน nginx ที่ตอบOPTIONSเอง หรือreturn 405กับ method ที่ไม่รู้จัก preflight จึงไม่ถึง PHP- preflight ผ่าน แต่คำขอจริงขึ้น CORS error ดู status ของคำขอจริง ถ้าเป็น
502หรือ504แปลว่า nginx ตอบเองตอน PHP ล่ม หน้า error ของ nginx ไม่มี header CORS เบราว์เซอร์จึงรายงานเป็น CORS ทั้งที่ปัญหาจริงอยู่ฝั่ง PHP ให้ไปดู log ของ Laravel ต่อ
เช็คได้เร็วกว่าเปิดหน้าเว็บ ด้วยการยิง preflight เองจาก terminal ใส่ header แบบที่เบราว์เซอร์ส่ง แล้วดูว่า server ตอบอะไรกลับมา
curl -i -X OPTIONS https://api.shop.test/orders/15 -H "Origin: https://shop.test" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: authorization, content-type"
ถ้า frontend กับ API อยู่โดเมนเดียวกันได้ ก็ไม่ต้องมี CORS เลย Laravel กับ Inertia หรือ Blade ที่เสิร์ฟหน้าเว็บกับ API จาก origin เดียวกัน ไม่มี preflight สักคำขอ รายละเอียดทุกเงื่อนไขของ CORS อ่านต่อได้ที่ MDN: Cross-Origin Resource Sharing






