JSON / API CSRF
CSRF on JSON endpoints used to be considered impossible because application/json requires a preflight. Modern attackers use fetch with credentials, custom content types, or browser quirks. Defend at every layer.
Lock down content-type, SameSite, tokens
EXAMPLE
// 1) The classical assumption (now incomplete)
// 'JSON APIs are CSRF-safe because forms can't send application/json without preflight.'
// True for the simplest cases — but only if the server REJECTS other content types.
// === Attack vectors that bypass the assumption ===
// 1a) Server accepts text/plain or no content-type
// Form-based: <form enctype='text/plain' action='https://victim/api/transfer' method='POST'>
// <input name='{"amount":1000,"to":"evil"}'>
// </form>
// Body posted: {"amount":1000,"to":"evil"}=
// If the server only checks the JSON SHAPE (not the content-type header), this works.
// 1b) Lax CORS + credentials
// fetch('https://victim/api/transfer', {
// method: 'POST',
// credentials: 'include', // browser sends cookie
// headers: { 'content-type': 'text/plain' }, // SIMPLE — no preflight
// body: JSON.stringify({ amount: 1000, to: 'evil' }),
// });
// Bypasses preflight; works if server doesn't enforce content-type.
// 1c) Subdomain takeover or trusted origin in CORS allowlist
// If api.example.com trusts *.example.com and an attacker takes over old.example.com,
// they have CORS + credentials — instant CSRF.
// === Defences ===
// 2) Require application/json content type on writes — and CHECK IT
import express from 'express';
const app = express();
app.use(express.json());
app.use((req, res, next) => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
const ct = req.headers['content-type'] ?? '';
if (!ct.includes('application/json')) {
return res.status(415).json({ error: 'Unsupported Media Type' });
}
}
next();
});
// 3) Require a custom header (forces preflight)
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
if (!req.headers['x-requested-with']) {
return res.status(403).json({ error: 'csrf' });
}
}
next();
});
// Client: fetch('/api/...', { headers: { 'X-Requested-With': 'XMLHttpRequest', 'content-type': 'application/json' } })
// 4) Strict CORS
import cors from 'cors';
const ALLOWED = new Set(['https://app.example.com']);
app.use(cors({
origin: (origin, cb) => cb(null, ALLOWED.has(origin ?? '')),
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
}));
// Never set Access-Control-Allow-Origin: * AND Allow-Credentials: true.
// 5) SameSite=Lax on the session cookie (default since Chrome 80)
app.use(session({
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}));
// 6) CSRF token — defence in depth
// Generate per session; require in a header on mutating requests.
app.get('/csrf', (req, res) => {
req.session.csrf ??= crypto.randomBytes(32).toString('base64url');
res.json({ token: req.session.csrf });
});
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const sent = req.headers['x-csrf-token'];
if (!sent || sent !== req.session.csrf) {
return res.status(403).json({ error: 'csrf' });
}
}
next();
});
// Client (TanStack Query example)
const { data: { token } } = useQuery({
queryKey: ['csrf'],
queryFn: () => fetch('/csrf').then(r => r.json()),
});
await fetch('/api/post', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
},
body: JSON.stringify(data),
});
// 7) Re-authenticate sensitive actions
// Password change / email change / payouts: require password re-entry or step-up MFA,
// even with valid session — limits damage from any residual CSRF window.
// 8) Origin / Referer checks
// As a belt-and-braces backup:
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const origin = req.headers.origin || req.headers.referer;
if (!origin || !ALLOWED.has(new URL(origin).origin)) {
return res.status(403).end();
}
}
next();
});
// 9) Token in cookie + header (double-submit) — stateless variant
// 1. Server sets a CSRF cookie (not HttpOnly so JS can read it)
// 2. Client copies cookie value into X-CSRF-Token on every write
// 3. Server compares cookie vs header
// Pros: works with serverless / stateless backends
// Cons: vulnerable to subdomain attacks unless cookie is __Host-prefixed
// 10) Anti-patterns
// ❌ Trusting Referer alone (browsers can be configured to strip it)
// ❌ Same token forever — rotate on auth state change
// ❌ Reading the token from a JS variable embedded in HTML by your own templating without CSP
// ❌ GET endpoints that mutate state — STILL vulnerable even with all the above
// 11) Real attacks observed in the wild
// - Subdomain takeover → CORS abused → drained crypto exchange accounts
// - Misconfigured framework default that accepted text/plain JSON → wire transfer fraud
// - Outdated CSRF lib that compared base64 token strings non-constant-time → token extraction via timing
// 12) Modern defence stack — assume you'll get one wrong, layer the rest
// SameSite=Lax cookies (browser default defence)
// Strict CORS allowlist (origin defence)
// Content-type enforced application/json (force preflight)
// X-Requested-With required (force preflight again)
// Per-session CSRF token in header (cryptographic check)
// Re-auth for sensitive actions (limits blast radius)
// Origin / Referer check (belt + braces)
// No GET-with-side-effects (architectural defence)
Why it matters
JSON-only is necessary, not sufficient. Enforce content-type, require a custom header, lock CORS to your domain, set SameSite=Lax, and add a CSRF token — each layer covers a gap the others miss.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// JSON endpoints aren't auto-safe. // Attacker page can POST a form with Content-Type: text/plain that the // server still parses if it allows JSON via that content type. // Defence: require a custom header (X-CSRF-Token / X-Requested-With) — preflighted by CORS.Try it Yourself »
Discussion
Loading…