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.
<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>
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.
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: "/",
});
}
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),
});
}
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 });
}