Skip to content
Knowledge Hub

CORS and CSRF: blocking the read of a response is not blocking the request Intermediate

Estimated reading time: 9 min read

A user is logged in to bank.example with a session cookie, then opens a page controlled by an attacker. That page auto-submits a POST form to the money transfer endpoint. If the browser attaches the cookie to the request and the server only checks the cookie, the money is transferred.

The foreign page cannot read the response (same-origin policy), but that doesn’t matter: the side effect has already happened. This is CSRF (cross-site request forgery).

A valid cookie does not mean the user intended to send the request
A valid cookie does not mean the user intended to send the requestStep 1/4: The user logs in; the browser keeps the bank.example session cookie.Browserlogged inbank.exampleevil.exampleforeign sitecookie
Browser → bank.examplecookie
Step 1/4

The user logs in; the browser keeps the bank.example session cookie.

CORS CSRF
What it is A mechanism that relaxes the same-origin policy: the server allows scripts on another origin to read the response An attack: an unintended request carrying the victim’s cookie
Who enforces it The browser The server must check on its own
Does it block the request? No (except requests that need a preflight) Only if the server has a token / source check
Misconfiguration leads to A foreign origin can read the user’s data Actions performed on the user’s behalf

Two things to remember: CORS is not a CSRF defense layer; and curl does not enforce CORS or SameSite, so don’t use it to test either.

Browsers split cross-origin requests into two kinds. Simple requests are sent straight away; the browser only stops the script from reading the response if the server does not allow it. Preflighted requests must go through an OPTIONS first.

Loading diagram…

Two practical consequences:

  • A traditional form POST (application/x-www-form-urlencoded) is a simple request, and HTML forms don’t go through CORS at all: they are sent regardless of the server’s CORS headers.
  • fetch with Content-Type: application/json or a custom header gets preflighted. So a JSON-only API is partially protected, but don’t treat that as your only CSRF measure: if the server parses JSON even when Content-Type is text/plain, a simple request still gets through.
Simple POST (form) JSON POST via fetch
Content-Type application/x-www-form-urlencoded application/json
Preflight No Yes
Server reached when CORS disallows it Yes, the request still arrives No, only OPTIONS arrives
  • For credentialed requests, browsers reject Access-Control-Allow-Origin: *. Allow-Headers, Allow-Methods and Expose-Headers cannot use * either. You must return a specific origin and Access-Control-Allow-Credentials: true.
  • When reflecting the origin from a dynamic allowlist, add Vary: Origin so caches don’t serve the response to the wrong origin.
  • Don’t echo the Origin header unconditionally; compare against an exact allowlist (no loose subdomain regexes).
  • Preflights never carry credentials. Access-Control-Max-Age allows caching the preflight result; the default is only 5 seconds and each browser has its own cap.
OPTIONS /transfer HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-csrf-token
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Vary: Origin

SameSite decides whether a cookie accompanies cross-site requests. It considers the site (registrable domain + scheme), not the origin: app.example.com and api.example.com are different origins but the same site.

Value The cookie is sent when
Strict Only same-site requests
Lax Same-site, and cross-site when it is a top-level navigation (changing the URL in the address bar, including clicking a link or submitting a form) and a safe method (not POST/PUT/DELETE). Not sent with fetch, <img>, <iframe>
None Every request; requires Secure

On defaults, MDN notes that some browsers treat cookies without a SameSite attribute as Lax (Chrome has done so since 2020). Don’t assume all browsers behave the same: always declare SameSite explicitly.

When Lax is the default (not declared explicitly), Chrome has an extra exception: the cookie is still sent with a cross-site POST if it was set less than 2 minutes earlier. Declaring SameSite=Lax explicitly removes this exception.

Lax blocks POST forms from foreign pages, but does not protect:

  • Endpoints that change state via GET (links and navigations also carry the cookie).
  • Requests originating from a same-site subdomain you don’t control.
  • Browsers that don’t default to Lax.

OWASP considers SameSite defense in depth, not a replacement for a dedicated CSRF defense.

Use at least two layers for every state-changing endpoint that relies on cookies:

Layer How Note
Fetch Metadata Reject unsafe methods when Sec-Fetch-Site: cross-site The header is set by the browser and JS cannot modify it; OWASP calls it the primary signal. Fall back to Origin for older clients
Origin check If Origin is present, it must match your origin Missing or null does not prove same-origin
CSRF token Synchronizer token (stored per session on the server) or signed double-submit Never put the token in the URL
Custom header The SPA sends its own header like X-CSRF-Token, forcing the browser to preflight CORS must be strict, no open subdomain regexes
SameSite Explicit Lax or Strict Only an additional layer

On double-submit cookies: the naive form (comparing a cookie with a header, two arbitrary values) is not safe because an attacker can set cookies via a subdomain.

OWASP recommends the signed form: the token is an HMAC of a session-bound value using a server-side secret. Signing without binding to the session is almost useless.

// Token = HMAC(secret, sessionId) — bound to the session, an attacker cannot forge it
import { createHmac, timingSafeEqual } from 'node:crypto';
const csrfToken = (sessionId: string) =>
createHmac('sha256', process.env.CSRF_SECRET!).update(sessionId).digest('hex');
function verify(sessionId: string, header: string | undefined): boolean {
if (!header) return false;
const a = Buffer.from(csrfToken(sessionId));
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}

Two other things that are easy to forget: never use GET to change state, and protect the login form too (login CSRF) with a pre-session token.

Situation CSRF defense?
Web/SPA authenticating via cookies, with data-changing endpoints Needed: tokens or Fetch Metadata + SameSite
API that only accepts Authorization: Bearer attached by the client, no cookies Usually not needed for CSRF; you still have to deal with XSS and where the token is stored
Server-to-server webhooks Don’t use CSRF tokens; verify signatures and idempotency
Public API, reads need no credentials No credentialed CORS needed; * is acceptable
Terminal window
npx --prefix examples ts-node examples/src/csrf/csrf-demo.ts

The demo checks the server-side decision with a fake session: the correct token is accepted (true), and a missing token (false) and a different random token (false) are rejected. There is no browser in the demo, so it does not illustrate SameSite or CORS.

  1. Every state-changing endpoint that uses cookies goes through a CSRF layer (token or Fetch Metadata); no route is “exempt” without a reason.
  2. Cookies explicitly declare HttpOnly; Secure; SameSite=Lax (or Strict if the flow allows).
  3. GET, HEAD, OPTIONS don’t change state.
  4. Reject Sec-Fetch-Site: cross-site for unsafe methods; fall back to checking Origin when the header is absent.
  5. CORS uses an exact origin allowlist, no * with credentials, and Vary: Origin.
  6. Tokens are bound to the session (HMAC or stored server-side), never placed in the URL.
  7. The login form has a token too; rotate the session ID after login.
  8. Test with a real browser from a foreign origin, not just curl.

Multiple layers of protection

  • Fetch Metadata and Origin are stateless and cheap to deploy.
  • Session-bound tokens block forged forms even when the cookie policy is configured loosely.
  • Strict CORS reduces the surface for cross-origin data reads.

Costs

  • Per-request tokens are stronger but make old forms and the back button awkward.
  • SameSite=Strict drops the login when users arrive from an external link.
  • XSS or untrusted subdomains invalidate many assumptions; they must be handled separately.

Self-check: (1) Why can a foreign page cause side effects without being able to read the response? (2) Which fetch requests get preflighted, and which don’t? (3) Why is a naive double-submit cookie unsafe?