A JSON API that authenticates with cookies is still exposed to CSRF. A reliable fix is to require a custom request header, such as X-CSRF-Token, on every state changing call and to keep your CORS allowlist tight. Browsers then preflight cross-origin calls, and your server refuses the ones it does not approve.
Why JSON endpoints are not automatically safe
Teams often assume that because the body is JSON, an HTML form cannot send it. That is only partly true. Forms can send form encoded, multipart or plain text bodies. If your server parses plain text as JSON, or ignores the content type, a form can still deliver a body that your handler accepts.
The first fix is simple: reject any request whose content type is not application/json. A JSON content type is not a safelisted type, so a cross-origin call using it triggers a preflight.
The custom header pattern
OWASP describes this as a control for endpoints that cannot use form tags. The flow is short.
- Your front end adds a header to every request that changes state.
- The server rejects requests missing the header.
- A page on another origin cannot add the header without a preflight, and your CORS policy does not approve it.
The header value can be a token. OWASP prefers header delivery for synchronizer tokens over hidden fields where JavaScript is involved, and says not to put tokens in URLs because URLs leak through history, logs and referrer headers.
Pick a token style
- Stateful sessions: a synchronizer token stored in the session, compared on each request.
- Stateless services: a signed double submit cookie. OWASP recommends an HMAC computed over the session ID and a random value, sent as a header or form field. Signing alone is not enough. Bind the token to the authenticated session.
- Avoid the naive version that just checks that a cookie equals a request value. An attacker who can write cookies can defeat it.
Where this breaks
- Permissive CORS. OWASP warns that any cross-origin request from an allowed domain can set custom headers. Allowing every subdomain is risky if one is taken over.
- XSS. Script in your origin can read the token and send valid requests, so the control relies on XSS prevention.
- GET with side effects. A header requirement means nothing if a link can trigger a change.
- Non browser clients. Mobile or server clients do not suffer CSRF the same way. If they use bearer tokens in headers rather than cookies, the browser never attaches credentials automatically.
A short implementation checklist
- Accept only
application/jsonon write endpoints. - Require the header and compare it on the server.
- Set the session cookie with
Secure,HttpOnlyandSameSite. - List exact origins in CORS, with
Vary: Origin. - Move every state changing GET to a write method.
- Test it: send a write request from a different origin and confirm it fails.
Quick check
Is a custom header enough on its own?
OWASP advises at least one defence in depth control as well, such as SameSite cookies or Origin and Referer checks.
Sources
Last reviewed: 19 October 2026.