CSRF Still Matters
SameSite cookies, SPAs, API patterns, and common mistakes
Goal: help you protect cookie-backed web apps and APIs from cross-site state-changing requests without cargo-culting
SameSite, CORS, or framework defaults.
TL;DR
- CSRF matters whenever the browser automatically attaches credentials such as session cookies.
- If your app uses cookies for auth, protect every state-changing request.
- Use your framework’s built-in CSRF protection when possible.
- SameSite helps, but it is not a complete defense.
- For classic server-rendered apps, use a synchronizer token.
- For API-driven apps that still use cookies, use custom headers, origin checks, and ideally Fetch Metadata as defense in depth.
- Don’t use GET for actions that change server state.
- CORS is not CSRF protection. In some cases, bad CORS config makes things worse.
1) Why CSRF Still Exists in “Modern” Apps
People hear about JWTs, SPAs, and fetch() and assume CSRF is old news.
It isn’t.
The core issue is simple: if the browser will automatically include credentials with a request, another site may be able to trick the browser into sending a request your server treats as authenticated.
That means CSRF still matters for:
- server-rendered apps with session cookies
- SPAs that call APIs using cookie-backed sessions
- cross-subdomain setups that share cookies
- admin panels and internal tools that rely on browser sessions
If your browser client authenticates using an Authorization: Bearer ... header that JavaScript adds manually, classic CSRF risk is much lower because the attacker’s site cannot make the browser attach that header automatically. But that does not solve XSS, token theft, or bad CORS.
2) The Threat Model in One Paragraph
A user is logged into app.example.com.
They visit evil.example.net in another tab.
That malicious site causes the browser to submit a form, load an image, or navigate to a URL on your app.
If the browser includes the user’s cookies and your server only checks “is this session valid?”, the action succeeds.
That is CSRF.
The attacker usually cannot read the response because of browser isolation rules, but for CSRF they often do not need to. The damage is the state change itself.
3) Start Here: Are You Actually CSRF-Exposed?
You are exposed if all of these are true:
- The request can change state
- The browser will automatically include credentials
- The server does not verify intent with a CSRF defense
High-risk examples
- change email
- change password
- create API key
- transfer money
- submit admin action
- add payment method
- invite user / change role
- delete data
Lower-risk cases
- pure read-only
GETpages - APIs called only by non-browser clients
- bearer-token APIs where browsers do not auto-send credentials
Even then, don’t get sloppy: frameworks, method override, and browser quirks can turn “it should be safe” into production regret.
4) The Practical Defense Matrix
| App type | Auth style | Primary defense | Extra defenses |
|---|---|---|---|
| Server-rendered web app | Session cookie | Synchronizer CSRF token | SameSite, Origin/Referer checks |
| SPA + same-origin API | Session cookie | Custom header + server token validation | SameSite, Fetch Metadata |
| SPA + BFF | Session cookie between browser and BFF | Framework/BFF CSRF protection | SameSite, Origin checks |
| API using bearer token in Authorization | No auto-sent cookie | Usually not classic CSRF-exposed | Focus on XSS, token storage, CORS |
| Cross-site embedded flow | SameSite=None cookie | Explicit CSRF token + origin policy | Tight CORS, narrow cookie scope |
If you only remember one thing, remember this:
Cookies create CSRF risk. Tokens prove intent. SameSite and Origin checks add guardrails.
5) Best Default for Server-Rendered Apps: Synchronizer Tokens
For stateful apps with server sessions, the default pattern is a synchronizer token.
How it works
- Server creates a random CSRF token tied to the user session.
- Server renders that token into forms or sends it to the frontend in a safe way.
- Browser submits the token with every state-changing request.
- Server validates that the token matches the session.
HTML form example
<form method="post" action="/account/email">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="email" name="email" />
<button type="submit">Save</button>
</form>
Backend check (pseudocode)
function requireCsrf(req, res, next) {
const sessionToken = req.session.csrfToken;
const requestToken = req.body.csrf_token || req.get("x-csrf-token");
if (!sessionToken || !requestToken || sessionToken !== requestToken) {
return res.status(403).send("Bad CSRF token");
}
next();
}
Per-session vs per-request tokens
- Per-session tokens are easier and usually enough.
- Per-request tokens reduce replay windows but can create UX annoyances like stale tabs and broken back-button flows.
For most product teams, per-session tokens + strong session handling is the practical default.
6) SPAs and Cookie-Backed APIs: Use a Custom Header
If your SPA talks to your API using session cookies, you still need CSRF protection.
A practical pattern is:
- browser gets a CSRF token from the app
- JavaScript sends it in a custom header
- server checks both the session and the CSRF token
Why this helps:
- attackers can submit cross-site forms
- attackers generally cannot make a plain HTML form send your custom header
- adding a custom header usually makes the request non-simple, which brings browser CORS rules into play for script-based cross-origin requests
Example: server issues token
app.get("/api/csrf", (req, res) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomUUID();
}
res.json({ csrfToken: req.session.csrfToken });
});
Example: frontend sends token
const { csrfToken } = await fetch("/api/csrf", {
credentials: "include",
}).then(r => r.json());
await fetch("/api/profile", {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"x-csrf-token": csrfToken,
},
body: JSON.stringify({ displayName: "Kuda" }),
});
Backend check
function requireCsrfHeader(req, res, next) {
const token = req.get("x-csrf-token");
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: "CSRF validation failed" });
}
next();
}
This is still not an excuse to set reckless CORS headers.
7) SameSite Helps — But It Does Not Finish the Job
SameSite controls when cookies are sent with cross-site requests.
Good defaults:
SameSite=Laxfor many session/navigation cookiesSameSite=Strictfor highly sensitive action cookies when UX allows itSameSite=None; Secureonly when you truly need cross-site cookie sending
Why SameSite is only partial protection
SameSite=Lax still allows some top-level navigations, especially with safe methods like GET.
That means if you put state-changing actions behind GET, or your framework supports method override in unsafe ways, you can still get burned.
Also, SameSite is based on site, not full origin. That means sibling subdomains can still be in play depending on how you scope cookies and trust infrastructure.
Set cookies tightly
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
Avoid broad Domain= settings unless you truly need them. Shared cookies across subdomains widen your blast radius.
8) Origin and Referer Checks: Cheap, Useful Defense in Depth
For state-changing requests, check the request origin.
Good pattern
- Accept only your own trusted origin(s)
- If
Originis absent, optionally fall back toReferer - Reject cross-site submissions to sensitive endpoints
Example
const TRUSTED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
function requireTrustedOrigin(req, res, next) {
const origin = req.get("origin");
const referer = req.get("referer");
if (origin && TRUSTED_ORIGINS.has(origin)) {
return next();
}
if (!origin && referer) {
try {
const url = new URL(referer);
if (TRUSTED_ORIGINS.has(url.origin)) return next();
} catch {}
}
return res.status(403).send("Untrusted origin");
}
Origin checks are not a replacement for CSRF tokens, but they are simple and valuable.
9) Fetch Metadata: A Very Useful Modern Guardrail
Modern browsers send Fetch Metadata headers such as:
Sec-Fetch-SiteSec-Fetch-ModeSec-Fetch-Dest
These let your server spot obviously cross-site requests and block them early.
Example: block cross-site unsafe requests
function requireSameSiteForUnsafeMethods(req, res, next) {
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
const site = req.get("sec-fetch-site");
if (unsafe && site && site === "cross-site") {
return res.status(403).send("Blocked by Fetch Metadata policy");
}
next();
}
This is excellent defense in depth, especially for modern browser traffic.
It is not enough by itself if you must support older clients, non-browser clients, or complex cross-site integration flows.
10) CORS Is Not CSRF Protection
This is one of the most common mistakes.
CORS controls whether browser JavaScript may read a cross-origin response. It does not stop a browser from sending every kind of cross-site request.
In fact, a bad credentialed CORS setup can reopen risk.
Dangerous pattern
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true
If your app accepts cross-origin credentialed requests from an origin you do not fully trust, and your CSRF story is weak, you have made life easier for attackers.
Safer rules
- never use
*with credentials - allow only exact trusted origins
- separate browser origins clearly
- do not assume “we have CORS configured” means “we handled CSRF”
11) Common Mistakes That Keep Showing Up
Mistake 1: “We use SameSite, so we’re done”
No. SameSite is helpful, but it is not a full CSRF defense.
Mistake 2: State changes over GET
If GET /delete?id=123 can change state, you are asking for trouble.
Mistake 3: Broad cookie domains
A cookie scoped across many subdomains means any weaker sibling app becomes part of your trust boundary.
Mistake 4: Trusting CORS to solve CSRF
It doesn’t.
Mistake 5: Disabling framework CSRF middleware “to make the SPA work”
Usually this means the architecture is confused, not that CSRF went away.
Mistake 6: Ignoring XSS
If attackers can run JavaScript in your origin, they can often read CSRF tokens or issue authenticated requests directly. CSRF defenses do not save you from XSS.
12) What to Use by Stack
Django / Rails / Laravel / Spring MVC / ASP.NET
Use the framework’s built-in CSRF protection first. Do not re-invent it unless you truly need custom flows.
Express / Fastify / custom Node backend
Use a maintained CSRF strategy and combine:
- session-backed token validation
SameSitecookies- origin checks
- Fetch Metadata
SPA + BFF
This is often the cleanest browser architecture:
- browser keeps only an HTTP-only session cookie
- BFF talks to upstream APIs
- BFF enforces CSRF protection on browser-originating mutations
Mobile apps / server-to-server APIs
Classic browser CSRF is usually not the main issue here. Focus more on auth, replay resistance, and token handling.
13) What “Good” Looks Like
- All state-changing routes require POST/PUT/PATCH/DELETE, never
GET - Session cookies are
HttpOnly,Secure, and use saneSameSite - State-changing requests require a CSRF token or equivalent validated intent signal
- Sensitive endpoints also check Origin and/or Referer
- Cross-site unsafe requests are filtered with Fetch Metadata where practical
- CORS allows only exact trusted origins and only where needed
- The team understands the difference between CSRF, CORS, and XSS
14) 2 AM Runbook: “Are We CSRF-Protected?”
- List every state-changing route.
- Confirm none of them use GET.
- Check whether the browser sends cookies automatically.
- Verify your framework or middleware is enforcing CSRF tokens.
- Confirm cookies are set with
HttpOnly; Secure; SameSite=.... - Check whether any cookie uses broad
Domain=scope. - Inspect CORS config for credentialed origins.
- Add or verify Origin/Referer checks on sensitive routes.
- Add Fetch Metadata blocking for unsafe cross-site browser requests.
- Test from a separate origin, not just from your local app.
If steps 3, 4, and 7 are fuzzy, you probably still have risk.
Appendix: Minimal Express Middleware Stack
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const TRUSTED_ORIGINS = new Set(["https://app.example.com"]);
function issueCsrf(req, res, next) {
if (!req.session.csrfToken) req.session.csrfToken = crypto.randomUUID();
next();
}
function requireTrustedOrigin(req, res, next) {
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
if (!unsafe) return next();
const origin = req.get("origin");
if (origin && !TRUSTED_ORIGINS.has(origin)) {
return res.status(403).send("Untrusted origin");
}
next();
}
function requireFetchMetadata(req, res, next) {
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
const site = req.get("sec-fetch-site");
if (unsafe && site === "cross-site") {
return res.status(403).send("Cross-site blocked");
}
next();
}
function requireCsrf(req, res, next) {
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(req.method);
if (!unsafe) return next();
const token = req.get("x-csrf-token");
if (!token || token !== req.session.csrfToken) {
return res.status(403).send("Bad CSRF token");
}
next();
}
app.use(issueCsrf);
app.use(requireTrustedOrigin);
app.use(requireFetchMetadata);
app.use(requireCsrf);
Ship it with framework defaults first, then layer SameSite, token validation, origin checks, and Fetch Metadata until cross-site requests fail closed.