CORS and CSRF: blocking the read of a response is not blocking the request Intermediate
Estimated reading time: 9 min read
The problem
Section titled “The problem”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).
cookieThe user logs in; the browser keeps the bank.example session cookie.
Quick summary
Section titled “Quick summary”| 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.
Simple requests and preflight
Section titled “Simple requests and preflight”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. fetchwithContent-Type: application/jsonor 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 whenContent-Typeistext/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 |
Configuring CORS correctly
Section titled “Configuring CORS correctly”- For credentialed requests, browsers reject
Access-Control-Allow-Origin: *.Allow-Headers,Allow-MethodsandExpose-Headerscannot use*either. You must return a specific origin andAccess-Control-Allow-Credentials: true. - When reflecting the origin from a dynamic allowlist, add
Vary: Originso caches don’t serve the response to the wrong origin. - Don’t echo the
Originheader unconditionally; compare against an exact allowlist (no loose subdomain regexes). - Preflights never carry credentials.
Access-Control-Max-Ageallows caching the preflight result; the default is only 5 seconds and each browser has its own cap.
OPTIONS /transfer HTTP/1.1Origin: https://app.example.comAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: content-type,x-csrf-token
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: trueAccess-Control-Allow-Methods: POSTAccess-Control-Allow-Headers: Content-Type, X-CSRF-TokenVary: OriginCookies and SameSite
Section titled “Cookies and SameSite”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.
CSRF defense layers
Section titled “CSRF defense layers”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 itimport { 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.
When it’s needed, when it isn’t
Section titled “When it’s needed, when it isn’t”| 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 |
Real-world example
Section titled “Real-world example”npx --prefix examples ts-node examples/src/csrf/csrf-demo.tsThe 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.
Production checklist
Section titled “Production checklist”- Every state-changing endpoint that uses cookies goes through a CSRF layer (token or Fetch Metadata); no route is “exempt” without a reason.
- Cookies explicitly declare
HttpOnly; Secure; SameSite=Lax(orStrictif the flow allows). GET,HEAD,OPTIONSdon’t change state.- Reject
Sec-Fetch-Site: cross-sitefor unsafe methods; fall back to checkingOriginwhen the header is absent. - CORS uses an exact origin allowlist, no
*with credentials, andVary: Origin. - Tokens are bound to the session (HMAC or stored server-side), never placed in the URL.
- The login form has a token too; rotate the session ID after login.
- Test with a real browser from a foreign origin, not just
curl.
Trade-offs
Section titled “Trade-offs”Multiple layers of protection
- Fetch Metadata and
Originare 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=Strictdrops the login when users arrive from an external link.- XSS or untrusted subdomains invalidate many assumptions; they must be handled separately.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”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?