skip to content

questions

3

How does requiring Content-Type application/json on a GraphQL POST block cross-site forgery?

level: middleimportance: must knowfreq 52%

answer

  1. Not every media type can be sent freely
  2. The browser must ask permission first
  3. Refused before dispatch, not after arrival
  4. Compare the type, ignore the parameters
  5. A lenient parser undoes all of it

basics

~20 s

A page cannot make a browser send a cross-origin body labelled application/json without the destination server first granting permission. Rejecting every other media type before parsing therefore means a forged POST is never dispatched at all.

solid answer

~50 s

A page can only cause the browser to send a cross-origin request body in a small set of media types — the form encodings and plain text — without the browser first asking the destination server whether that request is allowed. `application/json` is not in that set. So if the GraphQL endpoint accepts a JSON body and nothing else, an attacker's page has no way to produce an acceptable request: either it sends an acceptable media type, which the browser will not do without permission the server has not granted, or it sends one the endpoint refuses. Crucially the refusal happens in the browser, before dispatch, so the mutation never runs at all — unlike a token check, which rejects a request the server has already received. The check must be an exact media-type match performed before the body is parsed, and it must not be defeated by a permissive cross-origin policy that grants the attacker's origin the permission it lacked.

code

http · 6 lines
http
POST /graphql HTTP/1.1
Host: claims.example.com
Content-Type: application/json; charset=utf-8
Cookie: claims_session=7c1f9ab2e4d05531

{"query":"query ClaimSummary($id: ID!){ claim(id:$id){ id status insuredName } }","variables":{"id":"CLM-40318"}}

go deeper

for a junior

Recall that a web page cannot make a browser send a cross-origin JSON body without the destination server's permission, so an endpoint that accepts only JSON is very hard to forge against.

for a middle

Explain the mechanics: which media types a page can send freely, why the refusal happens before the request is dispatched rather than after it arrives, and why the check must run before the body is parsed.

for a senior

Demonstrate the failure modes you have actually seen: a lenient parser, a permissive cross-origin policy, a naive string comparison that rejects a charset parameter, and a rollout that broke an internal caller quietly.

for a principal

Own where this sits in a layered posture — ambient credentials versus header-borne ones, what you accept from non-browser callers, and who is allowed to relax a body parser. Argue the tradeoff rather than mandating the check.

## The rule this control rests on A page cannot make a browser send whatever it likes to another origin. For a small set of request shapes — the ones an ordinary HTML form or an image tag could always produce — the browser sends the request first and hides the response. For everything else it asks the destination server for permission before sending anything at all. The dividing line that matters here is the request's media type: only the form encodings and plain text belong to the "send it and hide the response" set. `application/json` does not. That single fact is the control. If your GraphQL endpoint executes an operation only when the request body arrives as `application/json`, then an attacker's page is stuck between two dead ends. It can send a media type the browser will dispatch without asking — and the endpoint refuses it. Or it can ask for `application/json` — and the browser stops to check with your server first, which does not know the attacker's origin and does not grant it. The distinction is worth stating precisely in an interview, because it is the reason this control is stronger than it looks: **the request is never dispatched**. A forgery token rejects a request that arrived; a media-type requirement means the mutation is never sent, so nothing executes, nothing is logged as a near miss, and nothing partially commits. ## What the specifications actually say Be careful attributing this. The GraphQL specification is transport-agnostic and says nothing about HTTP, media types or forgery. The GraphQL over HTTP work — a **working draft**, not a ratified specification — is where the HTTP-level rules live: a JSON request body is labelled `application/json`, servers support that media type, and GET is reserved for `query` operations so a mutation cannot travel by a route any tag can trigger. Treating a strict media-type check as your *anti-forgery control* is a deployment decision that follows from browser behaviour, not a rule the specification hands you. Say it that way and you will be right in every room. ## Implementing it without leaving a gap Four details separate a real control from a comforting one. **Check before you parse.** The gate belongs in front of the body reader. A server that parses the bytes, executes the operation and then notes the odd media type has already done the damage. **Match the media type, not the string.** `Content-Type` carries parameters. `application/json; charset=utf-8` is the same media type and must pass; `text/plain` must fail even when the bytes after it are perfect JSON. Compare the parsed type and subtype, case-insensitively, and ignore parameters. Equally, a missing `Content-Type` is not a JSON body — refuse it. **Refuse, do not sniff.** The single most common way this control is lost is a lenient body reader that will parse anything that starts with a brace. At that point the media type is decorative. **Watch the cross-origin policy.** The whole defence is "the browser asks, and the answer is no". A policy that reflects whatever origin asked, with credentials permitted, turns the answer into yes and hands the attacker exactly the permission the control depended on. A media-type rule and a permissive cross-origin policy do not coexist. ## The rollout, and the thing that breaks Enforcement is a behaviour change for every existing caller, and the callers you forgot are the ones that break. A worked example from a claims graph: the endpoint served a public client that posted proper JSON, plus an internal dashboard widget — written years earlier, still posting `text/plain` because a form helper defaulted to it — that fetched the summary panels for a `Claim`, a type carrying 37 fields spread across six panels. Turning the check on rejected roughly 1,180 of those widget requests a day, and because each panel fetched independently, the claims console did not fail loudly: it rendered half-empty, three panels populated and three blank, and was reported as "slow" for two days before anyone read a status code. The lesson generalises. Log the media type of every request for a week before enforcing, so you know the full set of callers. Enforce in report-only mode first and count what would have been refused, by caller. Expect one internal tool nobody remembered. And prefer a loud failure to a quiet partial one: a 4xx that the client surfaces beats a panel that silently renders empty. ## What it does not do It is a browser control, and only a browser control. A non-browser client sets any header it likes, so this stops forgery, not abuse: a script with a stolen cookie is unaffected. It says nothing about who may run an operation or how expensive that operation is. And it sits alongside, not instead of, the cookie's own cross-site rules and — if you keep ambient sessions — a forgery token. Defence in depth is the honest framing: this control is cheap, it is the one most specific to a GraphQL endpoint, and it fails the moment someone relaxes the parser "to be helpful".

  • Why is this stronger than a forgery token, and in what sense is it weaker?
    Stronger because the browser refuses to dispatch the request at all, so nothing reaches the server and nothing executes; a token rejects a request that has already arrived. Weaker because it depends entirely on browser behaviour and on your own strictness: a non-browser caller sets any header, and one lenient parser or one permissive cross-origin policy removes the control without changing a line of the check.
  • A request arrives with Content-Type: application/json; charset=utf-8. Should the check accept it?
    Yes. The media type is `application/json`; `charset` is a parameter and does not change it. Parse the header, compare type and subtype case-insensitively, and ignore parameters. A naive string equality against the bare media type rejects a legitimate caller, which is how teams get pressured into loosening the check for the wrong reason.
  • How would you roll this out on an endpoint that has been accepting anything for years?
    Measure first. Log the media type per caller for long enough to see weekly traffic, then run the check in report-only mode and count what it would refuse and from where. Expect an internal tool nobody remembers. Enforce once the count is zero, and make the rejection loud — a surfaced 4xx rather than a page that quietly renders half its panels.

It is a postbox with a slot of one exact shape: the attacker's page can only produce parcels in three shapes, and yours is a fourth.

saying these in an interview costs you the question

  • Thinks the server rejects the forged request after receiving it
  • Checks the media type after parsing the body
  • Rejects application/json; charset=utf-8 as a mismatch
  • Keeps a body parser that accepts any content type
  • Pairs the rule with an origin-reflecting cross-origin policy
  • Presents it as a rule the GraphQL specification defines

context

open as a page

A GraphQL server parses any POST body as JSON whatever the Content-Type says. How is that forged?

level: seniorimportance: should knowfreq 33%

basics

~20 s

A plain HTML form can send a cross-origin body as text/plain, with cookies attached and no permission step. Split across a field name and value, that body is valid JSON, so a lenient server executes it.

open as a page