Skip to content

Cybersecurity · Application Security

CSRF Protection in 2026: SameSite, Tokens, Origin Checks

SameSite cookies stop most CSRF, but not all. Learn when you still need tokens and Origin checks, with working Express code and a test you can run.

Abhishek Gupta

Abhishek Gupta

7 min read

A paper-cut cookie jar with a small padlock on its lid, a paper hand reaching for it past a low paper fence

Sponsored

Share

CSRF protection in 2026 takes three layers: a SameSite cookie, a server-side check of the Origin (or Sec-Fetch-Site) header on every state-changing request, and a CSRF token for anything that moves money or changes credentials. SameSite=Lax alone stops most attacks. It doesn’t stop all of them, and “most” is a poor standard for a bank transfer endpoint.

This guide follows the OWASP CSRF prevention cheat sheet and the MDN reference for the SameSite attribute. It shows why the attack works, where the browser defaults leave gaps, and a working Express setup you can drop in and test.

How a CSRF attack works

Browsers attach cookies to requests based on the destination, not on who started the request. If you’re logged in to bank.example and then open evil.example, a hidden form on the evil page can submit a POST to bank.example/transfer. Your browser dutifully adds the session cookie. The server sees a valid session and runs the transfer.

<!-- On evil.example -->
<form action="https://bank.example/transfer" method="POST" id="f">
  <input name="to" value="attacker" />
  <input name="amount" value="5000" />
</form>
<script>document.getElementById("f").submit();</script>

The attacker never reads the response, and doesn’t need to. The side effect is the point. That’s also why CORS doesn’t help: CORS stops pages from reading cross-origin responses, but the request has already landed.

What SameSite fixes, and what it doesn’t

Modern browsers treat cookies without a SameSite attribute as Lax. With Lax, the cookie is sent on top-level navigations using safe methods (GET), and withheld on cross-site POSTs, iframes, and fetches. That kills the form attack above.

It leaves gaps:

  • State change on GET. A Lax cookie still travels on a link click. If GET /delete-account?id=7 works, an <a href> or redirect does the job.
  • Same-site, cross-origin attacks. “Same-site” means the same registrable domain. An XSS bug or user-content upload on blog.example.com can attack app.example.com, and SameSite considers them friends.
  • Older or embedded clients. Not every browser or WebView applies Lax-by-default the same way.
  • Lax+POST grace windows. Some browsers have allowed brief exceptions for top-level POSTs shortly after a cookie is set. Don’t build a defense on edge-case timing.

SameSite=Strict closes more of this, but it also withholds the cookie when a user clicks a link into your app from an email or chat. They arrive looking logged out. Many teams use Strict for the session cookie on admin tools and Lax for consumer apps.

Layer 2: check Origin and Sec-Fetch-Site

On every unsafe method (POST, PUT, PATCH, DELETE), verify the request came from your own site. Browsers send Origin on cross-origin requests and on all POSTs. Recent browsers also send Sec-Fetch-Site, which says plainly whether the request is same-origin, same-site, cross-site, or none (user-initiated).

// csrf-origin.js
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);

export function originCheck(req, res, next) {
  if (!UNSAFE.has(req.method)) return next();

  const fetchSite = req.get("sec-fetch-site");
  if (fetchSite === "same-origin" || fetchSite === "none") return next();
  if (fetchSite === "cross-site" || fetchSite === "same-site") {
    return res.status(403).json({ error: "cross-site request blocked" });
  }

  // Header missing (older client): fall back to Origin, then Referer.
  const origin = req.get("origin");
  if (origin) {
    return ALLOWED_ORIGINS.has(origin)
      ? next()
      : res.status(403).json({ error: "bad origin" });
  }
  const referer = req.get("referer");
  if (referer && ALLOWED_ORIGINS.has(new URL(referer).origin)) return next();

  return res.status(403).json({ error: "missing origin" });
}

Notice that same-site is rejected. That’s deliberate. It closes the sibling-subdomain hole from the list above. If you legitimately call this API from another subdomain, add that origin to the allow-list and handle it explicitly.

Layer 3: a CSRF token for sensitive actions

An Origin check depends on headers the browser sets. A token depends only on a secret the attacker can’t read. For payments, password changes, email changes, and permission grants, use both.

The simple, stateless version is the double-submit cookie with a signed value:

// csrf-token.js
import crypto from "node:crypto";

const SECRET = process.env.CSRF_SECRET; // 32+ random bytes, never in source control

export function issueToken(sessionId) {
  const nonce = crypto.randomBytes(16).toString("hex");
  const mac = crypto
    .createHmac("sha256", SECRET)
    .update(`${sessionId}.${nonce}`)
    .digest("hex");
  return `${nonce}.${mac}`;
}

export function verifyToken(sessionId, token = "") {
  const [nonce, mac] = token.split(".");
  if (!nonce || !mac) return false;
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${sessionId}.${nonce}`)
    .digest("hex");
  const a = Buffer.from(mac, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Binding the token to the session ID matters. A token that isn’t tied to a session can be planted by an attacker who controls a sibling subdomain’s cookies.

Wire it into Express, with the cookie flags set:

import crypto from "node:crypto";
import express from "express";
import cookieParser from "cookie-parser";
import { originCheck } from "./csrf-origin.js";
import { issueToken, verifyToken } from "./csrf-token.js";

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(originCheck);

app.post("/login", (req, res) => {
  const sessionId = crypto.randomUUID(); // replace with your real session store
  res.cookie("sid", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
  });
  res.json({ csrfToken: issueToken(sessionId) });
});

app.post("/transfer", (req, res) => {
  const sessionId = req.cookies.sid;
  if (!sessionId || !verifyToken(sessionId, req.get("x-csrf-token"))) {
    return res.status(403).json({ error: "invalid csrf token" });
  }
  res.json({ ok: true });
});

The client keeps the token in memory (not in a cookie) and sends it as an X-CSRF-Token header. An attacker’s page can’t read that value, and a plain cross-site form can’t set a custom header at all.

Test that it actually blocks

Don’t trust a defense you haven’t attacked. With the server running locally:

# 1. Forged cross-site POST: should be 403
curl -i -X POST http://localhost:3000/transfer \
  -H "Cookie: sid=abc" \
  -H "Origin: https://evil.example" \
  -H "Sec-Fetch-Site: cross-site"

# 2. Same-origin POST but no token: should be 403
curl -i -X POST http://localhost:3000/transfer \
  -H "Cookie: sid=abc" \
  -H "Origin: https://app.example.com" \
  -H "Sec-Fetch-Site: same-origin"

Both must return 403. Put those two checks in your integration tests so a refactor can’t quietly drop the middleware.

Common mistakes

Putting the token in a cookie the server trusts blindly. Double-submit only works if the server verifies the value against something the attacker can’t forge, which is why the example signs it.

Exempting “internal” endpoints. The forged request comes from the user’s own browser, so the network location of the server doesn’t matter.

Skipping GET discipline. If a GET changes state, no cookie setting saves you. Make it POST, then protect the POST.

Assuming JSON means safe. A cross-site form can’t send Content-Type: application/json without a preflight, which helps. But servers that accept text/plain bodies and parse them as JSON undo that. Reject unexpected content types.

Forgetting WebSockets. The handshake carries cookies and isn’t covered by CORS. Check Origin on upgrade requests too.

When you can skip all this

If your API authenticates with a bearer token your own JavaScript puts in the Authorization header, and never reads a session cookie, classic CSRF doesn’t apply. The browser has no automatic way to attach that header to a forged request. Many single-page apps with token auth sit in this category. The catch is where the token is stored: if it’s in localStorage, you’ve traded CSRF for exposure to XSS. We cover that trade in our piece on OAuth 2 with PKCE and JWTs.

Where this fits in your security baseline

CSRF defenses sit next to the rest of your response headers and cookie settings. Our HTTP security headers checklist covers CSP and HSTS, and the OWASP API Security Top 10 walkthrough shows where broken authentication and authorization checks fit alongside CSRF.

Here’s the short version to apply today: set SameSite on every session cookie, reject cross-site unsafe requests by Sec-Fetch-Site and Origin, require a signed token on sensitive POSTs, and test all three with curl before you ship.

Frequently asked questions

Do I still need CSRF tokens if I use SameSite cookies?
For sensitive actions, yes. SameSite=Lax blocks most cross-site POST requests, but it doesn't protect against attacks from a sibling subdomain, legacy browsers, or endpoints that change state on GET. A token or an Origin check adds a second layer that doesn't depend on browser cookie behavior.
What is the difference between SameSite Lax and Strict?
Lax sends the cookie on top-level navigations that use safe methods like GET, so clicking a link to your site keeps the user logged in. Strict withholds the cookie on every cross-site request, including those link clicks, which can make users look logged out when they arrive from elsewhere.
Is a REST API with JWTs vulnerable to CSRF?
It depends on where the token lives. If the JWT is sent in an Authorization header by your JavaScript, a forged cross-site request won't carry it, so classic CSRF doesn't apply. If you store the JWT in a cookie and the server reads it from there, you have the same exposure as any session cookie.
Should I check the Origin header or the Referer header?
Check Origin first. Browsers send it on cross-origin POST requests and it contains only scheme, host, and port, so there are fewer privacy-stripping edge cases than with Referer. Fall back to Referer only when Origin is missing, and reject requests where both are absent on unsafe methods.
Does CORS protect me from CSRF?
No. CORS controls whether a page can read a cross-origin response. Simple form posts and some requests are sent anyway, and the damage happens on the server before the browser blocks anything. Treat CORS and CSRF defenses as separate controls.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored