Back to Networking
Networking
easy
mid

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.

10 min read·~5 min to think through

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, or POST.
  • Only CORS-safelisted headers: Accept, Accept-Language, Content-Language, Content-Type (with one of application/x-www-form-urlencoded, multipart/form-data, text/plain).
  • No ReadableStream body.
  • 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

ts
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.com

Important details

  • Access-Control-Max-Age caches 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 include Access-Control-Allow-Credentials: true AND 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-Age to 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.

ts
Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly
ValueSent on top-level cross-site nav (link click)?Sent on cross-site sub-resource (img/iframe/XHR)?
StrictNoNo
LaxYes (only safe methods: GET)No
NoneYesYes
  • 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). Secure is mandatory when SameSite=None.

Same-site vs same-origin

  • Same-origin = scheme + host + port match. https://app.example.comhttps://api.example.com.
  • Same-site = registrable domain matches (eTLD+1). https://app.example.com and https://api.example.com are same-site. https://example.com and https://example.org are 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)

AttackDefended by
evil.com fires JS fetch('https://bank.com/transfer', {credentials:'include'}) to read responseCORS (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.cookieHttpOnly cookie
MITM on insecure WiFi reads cookiesSecure 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: * plus Access-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.

Senior engineer discussion

Seniors should explain CORS and SameSite as solving different problems — CORS for cross-origin *read* of the response, SameSite for cross-site *attach* of the cookie — and know to use both plus CSRF tokens for state-changing endpoints. They should know modern browser defaults (Lax) flipped in 2020 and broke many embed/SSO flows that now require SameSite=None. Bonus: knowing the difference between same-site (registrable domain) and same-origin (scheme+host+port).

Related questions