skip to content

Why is a GraphQL endpoint that authenticates with a cookie exposed to cross-site forgery?

level: juniorimportance: should knowfreq 44%

answer

  1. The browser decides from the destination
  2. The attacker never sees the response
  3. One URL, one method, every write
  4. No token ships with the server
  5. What a page can make a browser send

basics

~20 s

A browser attaches the session cookie by destination, whatever page caused the request. So a page the victim visits can make their browser run a mutation for them: the attacker never reads the reply, but the write happens.

solid answer

~50 s

Cookie authentication is ambient. The browser decides to send the cookie from the destination of the request, not from the identity of the page that triggered it, so a request the attacker's page causes carries the victim's session exactly as the real application's request would. GraphQL adds nothing that stops this and one thing that concentrates it: every write is a mutation posted to the same single URL as every read, so an attacker needs no endpoint discovery, only the schema's field names. The attacker cannot read the response — cross-origin reads are refused unless the server opts in — but a mutation that transfers ownership of a claim or approves a payout has already executed by then. Nothing in the GraphQL specification defines a defence; a GraphQL server ships with no forgery token. What actually decides exploitability is whether the attacker's page can get the browser to send a request the endpoint will accept.

code

graphql · 6 lines
graphql
mutation ApproveClaim {
  approveClaim(claimId: "CLM-40318", amount: 24750) {
    id
    status
  }
}

go deeper

for a junior

Recall the core mechanic: the browser attaches a session cookie based on where the request is going, not on which page caused it, so a mutation can be triggered from a page the victim merely visited.

for a middle

Be able to explain why the attacker cannot read the response and why that makes writes the target, and why one endpoint plus one method means the whole attack reduces to what body a page can make a browser send.

for a senior

Show you know the exposure is decided by configuration, not by GraphQL: whether operations run over GET, whether the media type is enforced, and whether the session is ambient at all. Name the control you would actually deploy.

for a principal

Own the framing that ambient credentials are the design decision under the whole class. Argue when a cookie session is worth its defences at all versus a header-borne credential, and account for site-scoped cookie rules across subdomains you do not control.

## The shape of the attack Cross-site request forgery works because a browser's decision to attach a cookie is made from the **destination** of a request, not from the page that caused it. If a victim has an active session on an insurance claims application and then loads any other page — an advert frame, a forum post, a phishing link — that page can cause the victim's browser to issue a request at the claims origin, and the browser will attach the claims session cookie to it. From the server's side the request is indistinguishable from a real one: same cookie, same origin, same shape. The attacker is working blind. Cross-origin *responses* are not readable by the page that triggered them unless the server explicitly opts that origin in, so the attacker never sees the JSON that comes back. That is the whole reason forgery targets **writes**. A forged read is only interesting as a way to burn the server's capacity; a forged write — `approveClaim`, `addAdjuster`, `updateBankDetails` — has changed durable state before anyone notices the response went nowhere. ## What GraphQL adds, and what it does not GraphQL does not create this problem, but its shape changes the reconnaissance cost and concentrates the blast radius. **One endpoint, one method.** In a resource-style HTTP API an attacker has to find the path and method for the write. Here every read and every write is a POST to one URL. The only thing that varies is the body, and the body is a document written against a schema whose field names are frequently published, sometimes readable by introspection, and in any case inferable from a client bundle. If a `Claim` type carries 37 fields, the attacker does not need any of them; they need one mutation name. **No built-in token.** The specification defines a language, a type system and an execution algorithm. It defines no transport, no session, and no anti-forgery mechanism, and the GraphQL over HTTP work — still a working draft, not a ratified specification — describes how an operation travels, not how you authenticate it. A GraphQL server therefore ships with no forgery token; whatever defence exists is one you configured. **Requests the attacker cannot handcraft.** This is where it gets interesting, and it is the reason "GraphQL is CSRF-proof" is heard so often and is wrong in the general case. A page cannot set arbitrary request headers on a request it merely *causes*. A plain HTML form can send exactly three body encodings; an image tag, a script tag, a stylesheet link and a top-level navigation can only cause a GET. For the browser to send a body labelled `application/json` cross-origin, it must first ask the destination server for permission and get it. So a JSON-only endpoint is, in practice, hard to forge — not because GraphQL defends it, but because the attacker cannot make the browser produce the request the endpoint accepts. That protection lasts exactly as long as "JSON only" is true. Two things routinely make it false, and both are ordinary configuration rather than exotic bugs: a server that parses whatever body arrives without checking the media type, and an endpoint that also accepts a form-style encoding — the GraphQL multipart request convention uses `multipart/form-data`, which a plain form can produce. ## The GET case, which needs no cleverness at all If the endpoint executes operations sent as GET, forgery collapses to a single tag. An `<img src="https://claims.example.com/graphql?query=...">` on any page causes a request with cookies attached and no permission step whatsoever, because that kind of request is not subject to any cross-origin approval — the browser simply sends it and hides the result. This is exactly why the GraphQL over HTTP working draft reserves GET for `query` operations and directs a server receiving a mutation over GET to refuse it rather than execute it. Treat that as a hard rule regardless of which server you run, and note that it is a draft rule, not part of the GraphQL specification itself. ## The controls that actually apply Be explicit in an interview that these are ordinary web controls, not GraphQL features: - **Do not use ambient credentials.** A credential the browser attaches automatically is the precondition for the whole attack. A token the client code has to place into a request header cannot be attached by a page that is merely causing the request. - **Cookie attributes.** A cookie's `SameSite` attribute governs whether the browser attaches it to cross-site requests at all, and a modern default already blocks a great deal of this. Know two limits: applications that genuinely need cross-site cookies turn that default off, and the attribute is scoped to the *site*, so a compromised sibling subdomain is not cross-site. - **Media-type enforcement.** Requiring `application/json` and rejecting everything else before the body is parsed, which is this endpoint's most GraphQL-specific control. - **A forgery token**, if you keep cookie sessions and need a control that does not depend on browser defaults. The honest summary for an interviewer: cookie-authenticated GraphQL is forgeable in principle, is usually hard to forge in practice because of what a page can and cannot make a browser send, and becomes trivially forgeable the moment a server relaxes either the method rule or the media-type rule.

  • If the attacker cannot read the response, why is the attack worth mounting?
    Because the effect is the request, not the reply. A mutation that approves a payout, adds a user, or changes a notification address has already committed by the time the response is discarded. Forged reads are far less useful: the data goes nowhere the attacker can see, so their only value is consuming server capacity.
  • Does a single GraphQL endpoint make forgery easier or harder than a resource-style HTTP API?
    Easier to aim, no easier to send. There is nothing to discover — one URL, one method, and the write is named in the body — so reconnaissance is just learning a mutation name. But the body has to arrive in a media type a page can actually cause the browser to send, and that constraint is what decides exploitability, not the endpoint count.
  • Why does moving the session from a cookie to a request header remove the exposure?
    Because a header credential is not ambient. The browser attaches cookies on its own; it does not attach an Authorization header on its own. A page causing a cross-origin request cannot set that header, so the forged request arrives unauthenticated and is rejected on ordinary authentication grounds, with no anti-forgery machinery involved.

A cookie is like a building pass that the door reads automatically: it does not matter who told you to walk through the door, only which door you are standing at.

saying these in an interview costs you the question

  • Claims GraphQL is immune to CSRF by design
  • Thinks the same-origin policy stops the request being sent
  • Believes a GraphQL server ships with a forgery token
  • Says the attack is pointless without reading the response
  • Assumes an unguessable endpoint path is a defence
  • Treats forged mutations and forged queries as equally damaging

context