CSRF protection for cookie-based API
When your API uses cookies (not bearer tokens), CSRF matters
If your API uses cookies for auth (like Kratos session), you need CSRF protection on every state-changing endpoint.
Why
Attacker site:
<form action="https://your-api.com/transfer" method="POST">
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit();</script>User visits attacker site. Browser sends cookies (same-origin to your-api.com). Transfer happens.
Without CSRF protection: succeeded.
When you DON'T need CSRF
If your API:
- Only accepts
Authorization: Bearer ...(no cookies). - Has
SameSite=Strictcookies (rare; breaks redirect flows).
Then CSRF can't happen, attacker site can't send the credentials.
Olympus's defaults
Kratos session cookie: SameSite=Lax. Means:
- Top-level navigation: cookie sent.
- Cross-origin POST/fetch: cookie NOT sent.
This blocks basic form-based CSRF. But:
- Top-level GET with side effects (don't do this anyway).
- Some browsers with bugs.
- Future Same-Site=None scenarios.
Defense in depth: still add CSRF tokens.
Pattern: synchronizer token
// Server, on each request:
const csrfToken = req.cookies.csrf_token || generateToken();
res.cookie("csrf_token", csrfToken, { httpOnly: false, sameSite: "lax", secure: true });
// JS can read this cookie
// On state-changing endpoints:
function validateCsrf(req) {
const headerToken = req.headers["x-csrf-token"];
const cookieToken = req.cookies.csrf_token;
if (!headerToken || headerToken !== cookieToken) {
throw new Error("csrf_violation");
}
}
app.post("/transfer", (req, res) => {
validateCsrf(req);
// ...
});Client:
const csrf = getCookie("csrf_token");
await fetch("/transfer", {
method: "POST",
headers: { "x-csrf-token": csrf },
body: ...
});JS reads cookie, sends in header. Attacker can't replicate (cross-origin can't read cookies).
Pattern: double-submit cookie
Similar, token in cookie AND header. Both must match.
Difference: synchronizer stores token in session server-side; double-submit doesn't. Simpler.
Library
Most frameworks have built-in:
import { csrf } from "next/csrf";
app.use(csrf({ cookie: { httpOnly: false } }));
app.post("/transfer", csrf.validate, (req, res) => { ... });Or Hono:
import { csrf } from "hono/csrf";
app.use("*", csrf());Cleaner than rolling your own.
SameSite as primary defense
Set-Cookie: session=...; SameSite=LaxCombine with CSRF token. Defense in depth.
Origin / Referer check
Additional layer:
if (req.method === "POST") {
const origin = req.headers.origin || req.headers.referer;
if (!origin || !ALLOWED_ORIGINS.includes(new URL(origin).origin)) {
throw new Error("invalid_origin");
}
}Origin / Referer can be spoofed in some scenarios but usually not from a malicious site.
Don't rely solely on Origin (some browsers omit). Use as secondary.
JSON-only APIs
If your API only accepts Content-Type: application/json, that itself is a soft CSRF defense, browsers can't send JSON content type from a cross-origin form (without CORS preflight). Most CSRF attacks use form-style.
if (req.method === "POST" && req.headers["content-type"] !== "application/json") {
throw new Error("invalid_content_type");
}Combine with token.
Custom header trick
A request with a custom header triggers CORS preflight. Attacker site can't issue preflight successfully without your CORS config allowing it.
if (!req.headers["x-requested-with"]) {
throw new Error("missing_x_requested_with");
}Client:
fetch("/api/...", {
headers: { "x-requested-with": "XMLHttpRequest" },
});This is what jQuery used to do. Effective.
CSRF on Kratos flows
Kratos's selfservice flows have built-in CSRF tokens. Forms include hidden csrf_token field. You don't need to add CSRF for Kratos flows.
For your OWN API endpoints, add it.
Test
# Without token, should fail
curl -X POST https://your-api.com/transfer \
-H "Cookie: session=valid" \
-d "amount=100"
# Response: 403 csrf_violation
# With token, should succeed
curl -X POST https://your-api.com/transfer \
-H "Cookie: session=valid; csrf_token=X" \
-H "X-CSRF-Token: X" \
-d "amount=100"
# Response: 200File uploads
CSRF for multipart forms is trickier (can't easily set header on form submit). Either:
- Use AJAX upload (XHR / fetch), can set headers.
- Require explicit CSRF token in form field.
<form enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf }}" />
<input type="file" name="file" />
<button>Upload</button>
</form>CSRF and SPAs
For pure SPAs using bearer tokens (not cookies), CSRF is moot. The attacker site can't read or send tokens.
For SPAs using cookies (via BFF), CSRF protection on the BFF's API.