Skip to main content
Published
CSRFSecurityCookies

Cross-site request forgery for cookie sessions

CSRF against cookie-authenticated frontend apps and BFFs: SameSite, anti-forgery tokens, and why GET must stay read-only.

Cross-site request forgery tricks a logged-in browser into sending a state-changing request the user did not mean to send. The browser attaches cookies. Your API thinks it is a normal session.

Frontend apps that rely on cookie sessions feel this first. Bearer tokens in memory change the shape. Cookie-based BFFs and classic session apps need an explicit CSRF story.

What the attack looks like

User is logged into your product. They open a hostile page. That page submits a form or fires a fetch to your origin. If cookies are sent automatically and you accept the mutation, the damage is done.

attacker/evil.htmlhtml
<form action="https://app.example.com/api/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="500" />
</form>
<script>document.forms[0].submit()</script>
Classic form CSRF. Same idea works with image tags on GET if you mutate on GET.

Defences that work in real SPAs and BFFs

SameSite cookies block many cross-site cases. They are not a full substitute for anti-forgery tokens on sensitive POSTs.

Synchronizer tokens or double-submit cookies force the hostile page to know a value it cannot read cross-origin. Custom headers help when only your JS can set them and CORS is tight. Always validate the token on the server.

  • GET must not change state.
  • Prefer SameSite=Lax or Strict with intent.
  • Validate Origin or Referer on sensitive cookie endpoints when practical.
  • Compare CSRF tokens with a constant-time check.
lib/sessionCookie.tsts
import { cookies } from "next/headers";
 
export async function setSessionCookie(token: string) {
  const jar = await cookies();
  jar.set("session", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
  });
}
Session cookie posture for first-party apps.
lib/csrfFetch.tstsx
export async function mutate(path: string, body: unknown) {
  const csrf = document
    .querySelector('meta[name="csrf-token"]')
    ?.getAttribute("content");
 
  if (!csrf) throw new Error("Missing CSRF token");
 
  return fetch(path, {
    method: "POST",
    credentials: "same-origin",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrf,
    },
    body: JSON.stringify(body),
  });
}
Client sends the CSRF token. Server must compare it to the expected value.
app/api/transfer/route.tsts
import { cookies } from "next/headers";
import { timingSafeEqual } from "crypto";
 
function safeEqual(a: string, b: string) {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}
 
export async function POST(req: Request) {
  const jar = await cookies();
  const expected = jar.get("csrf")?.value ?? "";
  const provided = req.headers.get("x-csrf-token") ?? "";
 
  if (!expected || !safeEqual(expected, provided)) {
    return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
  }
 
  // Origin check for cookie endpoints
  const origin = req.headers.get("origin");
  if (origin !== "https://app.example.com") {
    return Response.json({ error: "Invalid origin" }, { status: 403 });
  }
 
  // ... perform the mutation
  return Response.json({ ok: true });
}
Reject mutations when the CSRF header does not match the session token.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract