How do CORS preflight requests and SameSite cookies work together?
CORS preflight: for non-simple cross-origin requests (PUT, DELETE, custom headers, JSON Content-Type), the browser sends an OPTIONS request first asking 'is this method+headers allowed from this origin?' Server must respond with matching Access-Control-Allow-* or the real request never fires. SameSite cookies (Lax/Strict/None) control when cookies attach to cross-site requests — Lax is the modern default and blocks most CSRF; None requires Secure. Together they cover two different attack surfaces: CORS protects responses, SameSite protects cookies.
Two separate browser features that often get confused because both touch cross-origin requests.
CORS preflight
A preflight is an extra OPTIONS request the browser fires before a "non-simple" cross-origin request to check permission. If preflight fails, the real request is never sent.
What triggers a preflight
The request is preflighted unless all of these are true:
- Method is
GET,HEAD, orPOST. - Only CORS-safelisted headers:
Accept,Accept-Language,Content-Language,Content-Type(with one ofapplication/x-www-form-urlencoded,multipart/form-data,text/plain). - No
ReadableStreambody. - No event listeners on
XMLHttpRequest.upload.
In practice almost any modern API call (Content-Type: application/json, Authorization: Bearer …, PUT/DELETE) triggers a preflight.
The handshake
Browser → Server (preflight):
OPTIONS /api/users/42
Origin: https://app.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
Server → Browser:
204 No Content
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400
Browser → Server (real request):
PUT /api/users/42
Authorization: Bearer …
Content-Type: application/json
Origin: https://app.comImportant details
Access-Control-Max-Agecaches the preflight in the browser (Chrome caps ~2h, Firefox 24h). Without it you get an OPTIONS on every single request.- Credentials: if the real request sends cookies (
credentials: 'include'), preflight response must includeAccess-Control-Allow-Credentials: trueAND echo the specific origin (*is rejected with credentials). - OPTIONS must succeed (2xx) even for endpoints that don't normally accept OPTIONS. CORS middlewares handle this automatically.
- Auth middleware should skip OPTIONS — preflights don't carry credentials by design.
Reducing preflight overhead
- Set
Access-Control-Max-Ageto hours/days. - Where possible, use simple requests (form-encoded POSTs avoid preflight).
- Same-origin via subdomain + reverse proxy completely sidesteps CORS.
SameSite cookies
A cookie attribute that controls when the cookie is sent on cross-site requests.
Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly| Value | Sent on top-level cross-site nav (link click)? | Sent on cross-site sub-resource (img/iframe/XHR)? |
|---|---|---|
| Strict | No | No |
| Lax | Yes (only safe methods: GET) | No |
| None | Yes | Yes |
- Lax (modern browser default since 2020): blocks most CSRF because attacker's site can't get the cookie attached to a hidden POST.
- Strict: even safer, but breaks UX (clicking a link from email/Slack to your site arrives without session — user looks logged out).
- None: required for genuinely cross-site cookies (embedded widgets, third-party iframes, OAuth-style flows).
Secureis mandatory when SameSite=None.
Same-site vs same-origin
- Same-origin = scheme + host + port match.
https://app.example.com≠https://api.example.com. - Same-site = registrable domain matches (eTLD+1).
https://app.example.comandhttps://api.example.comare same-site.https://example.comandhttps://example.orgare not.
A cookie with SameSite=Lax on example.com will be sent on requests from api.example.com (same-site). SameSite is laxer than CORS.
How they work together (and where they don't overlap)
| Attack | Defended by |
|---|---|
evil.com fires JS fetch('https://bank.com/transfer', {credentials:'include'}) to read response | CORS (response not exposed) |
| evil.com fires the same fetch and doesn't care about the response — just wants the side effect (CSRF) | SameSite=Lax cookie (cookie not attached) |
evil.com embeds <img src="https://bank.com/transfer?to=evil&amt=1000"> | SameSite cookie |
| Third-party script reads a sensitive cookie via document.cookie | HttpOnly cookie |
| MITM on insecure WiFi reads cookies | Secure cookie + HTTPS |
CORS guards the response from being read by JS on a different origin. It does not prevent the request from arriving. SameSite cookies guard against the cookie being attached to a cross-site request, which kills the credential-replay vector of CSRF.
You typically want both: SameSite=Lax + HttpOnly + Secure on session cookies, plus correct CORS configuration on APIs that need cross-origin reads. Plus a CSRF token (double-submit cookie or origin/sec-fetch-site check) as defense-in-depth for state-changing endpoints.
Common production mistakes
- API server doesn't handle OPTIONS → preflights 404 → no requests work, devs blame "CORS" when really the route is missing.
Access-Control-Allow-Origin: *plusAccess-Control-Allow-Credentials: true→ browser rejects.- Forgot
Max-Age→ preflight on every request → 2x request count, 2x latency. - Cookie set without SameSite → modern browsers treat as Lax → cross-site embeds break silently.
- Cookie set with SameSite=None but no Secure → modern browsers reject it.
- Relying on CORS alone for CSRF protection — CORS doesn't stop the request, just the response read.
Follow-up questions
- •What's the difference between same-site and same-origin?
- •Why doesn't CORS by itself prevent CSRF?
- •When would you use SameSite=None?
- •How do CSRF tokens complement SameSite cookies?
Common mistakes
- •Treating CORS as a request firewall — it only blocks JS from reading responses.
- •Forgetting Access-Control-Max-Age and paying for preflight on every request.
- •SameSite=None without Secure — silently rejected by browsers.
- •OAuth or embed flows breaking after browser default flipped to SameSite=Lax — needed explicit None+Secure.
- •Auth middleware running on OPTIONS preflight — preflight has no credentials, auth fails, CORS appears broken.
- •Setting Access-Control-Allow-Origin: * with cookies — browsers reject.
Performance considerations
- •Preflights double the request count for non-simple cross-origin calls. Set Max-Age to the maximum the browser allows. Where latency matters and you control both ends, proxy through your own origin or use a same-site subdomain to skip CORS entirely. SameSite cookies have no perf cost.
Edge cases
- •Browsers handle preflight caching per (origin, URL) pair — adding a query param invalidates the cached preflight.
- •Some headers (like Cookie, Host, User-Agent) are forbidden and never trigger preflight regardless.
- •Service Worker fetches inherit the page's CORS rules, but they can also serve cached responses, bypassing the network entirely.
- •First-party Sets (FPS) is a newer concept that lets related domains opt into shared same-site treatment.
- •Safari has stricter cookie policies than Chrome/Firefox (ITP) — test cross-domain auth there too.
Real-world examples
- •Stripe.js embeds iframes that use SameSite=None + Secure for cross-site cookie flow.
- •OAuth/SSO flows often need SameSite=None for the auth provider's cookie.
- •Cloudflare Workers and similar edge providers default to wide-open CORS for public APIs; private APIs configure tight origin lists.