Olympus Docs
CookbookDefensive security

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=Strict cookies (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).

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=Lax

Combine 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: 200

File 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.

On this page