Security

Ask Anything, Within Reason

GraphQL lets the client decide what a request costs and which path it takes to the data. Hardening it means putting the limits back, on introspection, depth and cost, on who can reach each object, on what a browser can send for you, and on which operations are allowed at all.

A map before you read

One line per section, written by an AI model running entirely in your browser. Free, private, nothing leaves your device.

GraphQL’s best feature is that the client asks for exactly what it needs, in one request, in a shape the server never had to anticipate. It’s the feature that let a single page at Allē pull from half a dozen services, which I wrote about in One Graph, Many Sources. It’s also the feature that makes GraphQL harder to defend than the REST API it usually replaces. A REST endpoint decides what a request costs. In GraphQL, the client does.

Hardening a GraphQL API is the work of putting back the limits the query language took out, on what can be asked, how much of it, and by whom.

Most public GraphQL APIs haven’t finished that work. In July 2024, the security company Escape scanned 160 major public GraphQL endpoints and found 13,720 issues, “with 33% of API services having at least one or more high-severity issues,” along with “4,400 secrets exposed in public GraphQL APIs” (Escape). The problems fall into two families. Some requests ask for too much, and the server spends itself answering. Others ask for something they shouldn’t see, and the server answers anyway.

The __type Bomb

Every GraphQL schema describes itself. Introspection lets a client ask the server for its types, each type’s fields, each field’s type, and so on, which is how tools like GraphiQL know what to autocomplete. The trouble is the “and so on.” A type has fields, a field has a type, and that type has fields of its own, so the schema of a schema is a cycle, and a query can follow it as deep as it likes:

{
  __schema {
    types {
      fields {
        type {
          fields {
            type {
              fields { type { fields { name } } }
            }
          }
        }
      }
    }
  }
}

The same shape works from __type(name: "Provider"), and through interfaces, possibleTypes and ofType. Every fields is a list, so every level multiplies the response by the average number of fields per type. The maintainers of graphql-java describe the problem in their own source code. “Schemas form a cyclic graph and hence it’s possible to send in introspection queries that can reference those cycles and in large schemas this can be expensive and perhaps a ‘denial of service’” (graphql-java).

graphql-js 16.9+ rejects these1k10k100k1M10M100M1B1234567Nested fields { type { fields levels in one queryObjects in the response (log)GitHub’s cap on a whole query: 500,000 nodes2k419.4M
Objects in the response as an introspection query nests deeper, for an illustrative schema of 200 types averaging 8 fields. Each level multiplies the response by 8. graphql-js now rejects a third nested list; GitHub’s 500,000-node cap on a whole query is shown for scale.

The libraries have caught up, recently. graphql-js added a MaxIntrospectionDepthRule in version 16.9.0, in June 2024, and turned it on by default. It counts nested fields, interfaces, possibleTypes and inputFields lists and rejects the third with “Maximum introspection depth exceeded” (graphql-js). graphql-java’s “good faith” introspection allows one __schema and one __type per operation, and its first version could be bypassed, which became CVE-2024-40094 in July 2024 (NVD). In February 2026, GitLab patched a high-severity denial of service in its GraphQL introspection that “could have allowed an unauthenticated user to cause denial of service by sending repeated GraphQL queries,” in every version back to 10.8 (GitLab).

Notice where the usual first defense falls short. A depth limit is the first thing most teams add, and graphql-armor, a popular set of protections for JavaScript GraphQL servers, ships one with a default of six. Its options include one to “Ignore the depth of introspection queries,” on by default, and the code returns a depth of zero for anything under __schema (graphql-armor). The limit counts everything except the query this attack is made of.

Turn the Lights Off

The simplest defense is to stop answering. The OWASP GraphQL Cheat Sheet says to “Disable introspection queries system-wide in any production or publicly accessible environments” (OWASP). Some servers do it for you and some don’t. Apollo’s GraphOS Router turns introspection off by default (Apollo), while Apollo Server leaves it on “unless the NODE_ENV environment variable is set to production” (Apollo), which is one forgotten environment variable from a public schema.

Turning introspection off hides the map, not the territory. GraphQL servers helpfully answer a misspelled field with “Did you mean…,” and graphql.org warns that “even with introspection disabled, an attacker could ultimately infer the shape of an entire schema by running numerous operations with incorrect field names” (GraphQL). There’s a tool for exactly that, Clairvoyance, which promises to “Obtain GraphQL API schema even if the introspection is disabled” (Clairvoyance). So suggestions go too. Apollo Server has a setting that “strips out ‘did you mean’ suggestions,” and graphql-armor replaces them with “[Suggestion hidden].” A hidden schema isn’t a secure one, but there’s no reason to hand it out.

Aliases, Batches and Directives

The introspection bomb is the famous attack, but the rest of the language has the same property, that one request can ask for a lot. Aliases let a client request the same field many times under different names, and graphql.org’s own example asks for a user’s friends a hundred times in one operation, friends1 through friends100. Batching lets a client send many operations in one HTTP request, which OWASP calls “a form of brute force attack, specific to GraphQL, that usually allows for faster and less detectable exploits,” because a rate limiter counting requests sees one where an attacker tried a thousand passwords or one-time codes. Directives can be repeated too. In graphql-java, CVE-2022-37734 let a single field carry thousands of them, and Checkmarx showed that “By launching 50 concurrent malicious requests with 30.000 directives, the server becomes unavailable” (Checkmarx). The Rust library async-graphql had the same flaw as late as October 2024, CVE-2024-47614, because it “does not limit the number of directives for a field” (NVD).

Each of these has a limit, and a good router lets you set them all. Apollo’s Router has max_depth for nesting, max_height for the number of fields, max_aliases, and max_root_fields, along with parser limits that cap an operation at 15,000 tokens and 500 levels of recursion by default (Apollo). It also has a warn_only mode, which logs operations over the limits without rejecting them. That’s the right way to turn limits on in a graph that real clients already depend on, the same way schema checks in CI tell you which clients a change will break before it ships.

Price Every Query

Limits on shape still leave cost. Two operations with the same depth can differ by orders of magnitude, depending on whether the list at the bottom returns ten items or ten thousand. The large public GraphQL APIs answer this by pricing queries before they run. GitHub gives each user “5,000 points per hour,” requires every first and last argument to be between 1 and 100, and refuses any call that would touch more than “500,000 total nodes” (GitHub). Shopify charges by calculated cost from a leaky bucket that refills at 100 points a second on its standard plan, and says “A single query may not exceed a cost of 1,000 points, regardless of plan limits” (Shopify).

The idea worth copying is that the rate limit counts cost, not requests. A batch of a hundred login attempts costs a hundred logins, and a query that fans out across ten thousand rows costs ten thousand rows, however few HTTP requests carried them. Apollo calls its version demand control, and its documentation gives the same advice as for every other limit: “When enabling demand_control for the first time, set it to measure mode” (Apollo).

Every Field Is a Door

Denial of service is the attack GraphQL is famous for, but it isn’t where the data leaks. Nearly every disclosed GraphQL data leak I could find came from authorization, and they share a cause. A REST API has a handful of endpoints, each a natural place to check who’s asking. A GraphQL API has one endpoint and hundreds of fields, and the schema offers many paths to the same object. A check at the endpoint checks almost nothing, and a check on one path leaves the others open.

HackerOne learned that about itself. In June 2022, a researcher sent its GraphQL API a query for a single object through node(id:), the Relay-style field that fetches anything by its global ID, without signing in. The IDs could be enumerated, and the answers exposed private programs’ names and scope and the titles of private reports. HackerOne rated it critical and paid $25,000 (HackerOne). In 2023, Shopify paid $5,000 for an insecure direct object reference in two billing queries that returned other merchants’ email addresses, full addresses and invoices (HackerOne). Uber’s restaurant GraphQL service, in 2021, “did not properly perform an authZ check, allowing an attacker to obtain detailed sales statistics, etc for any restaurant” (HackerOne). The tools get it wrong too. In November 2025, Apollo disclosed that its Router had “allowed for unauthenticated queries to access data that required additional access controls,” because it mishandled access control directives on interface types (GitHub Advisory, CVE-2025-64173).

So where should the check live? graphql.org’s guidance is that “Authorization logic should be delegated to the business logic layer, not the GraphQL layer” (GraphQL). Notice why. The GraphQL layer is where the paths multiply, and the business layer is where they meet. If the check happens where an invoice is loaded, then node(id:), _entities, a nested field and a top-level query all pass through it, and a new path added next year does too. It’s the same argument I made about tenancy in Where the Tenant Boundary Lives. Put the boundary where no query can route around it.

Requests the Browser Sends for You

The other way to get data you shouldn’t is to borrow someone else’s session. Browsers send some cross-site requests without asking the server first, simple GETs and form posts, with the victim’s cookies attached. Why should a GraphQL server care? Apollo’s documentation answers plainly. “GraphQL servers should not execute any operation in a ‘simple request’” (Apollo). Apollo Server 4 and later turn on CSRF prevention by default, which refuses any operation that doesn’t arrive as JSON or carry a custom header, because both force the browser to check first. Servers that accept mutations over GET don’t have that protection, and in August 2026 GitLab patched exactly that, a flaw that let attackers “execute mutations via GET requests due to improper request validation in GraphQL multiplex query handling” (GitLab).

Subscriptions have their own version. WebSockets aren’t covered by the browser’s cross-origin checks, so a server has to check the Origin of every connection itself, and in June 2026 Spring for GraphQL disclosed that applications using its WebSocket transport were open to cross-site WebSocket hijacking (Spring). The defenses are unglamorous. Mutations only over POST with a JSON body, the origin checked on every WebSocket handshake, and authentication carried in the connection rather than in a cookie the browser will attach to anyone’s request.

It’s Still an API

None of this replaces the security work every API needs. GraphQL arguments reach resolvers the way any input reaches a handler, and they can carry the same attacks. In June 2026, WPGraphQL disclosed an unauthenticated SQL injection (GitHub Advisory, CVE-2026-40762). In August 2026, GitLab patched a critical code injection that “could allow an unauthenticated user to remotely modify or delete public projects and user data via a GraphQL directive” (GitLab). And in September 2026, attackers exploiting StyleSmuggler, a remote code execution flaw in Magento and Adobe Commerce, sent their first probe as an ordinary POST /graphql with a harmless query as cover (Sansec). OWASP’s rules for errors are the old ones too, no stack traces, no debug mode, and no GraphiQL in production.

The defenses have bugs of their own, which is the argument for staying current. In April 2025, Apollo fixed a way to bypass its Router’s operation limits through an integer overflow (GitHub Advisory, CVE-2025-32033). A limit configured once is only as good as the version enforcing it.

Only Ask What We Wrote

All of that is defense against operations nobody on the team wrote. For a first-party API, the strongest defense is to refuse them entirely. Trusted documents, also called persisted queries or safelisting, register every operation your own apps send at build time, and the server rejects anything else. graphql.org’s recommendation is to “lock those operations so that only they may be sent by front-end apps in production,” and Apollo’s router, with safelisting enabled, “rejects any incoming operations not registered in its PQL” (Apollo). Benjie Gillam, who maintains PostGraphile, puts the payoff simply. With an allowlist, “you no longer need to concern yourself with malicious documents” (Benjie).

The fair counterargument is that this throws away the flexibility GraphQL was chosen for. For a public API, it does, and graphql.org says plainly that trusted documents can’t be used when third-party clients write their own operations. But most GraphQL APIs aren’t public. They serve a company’s own web and mobile apps, whose operations are known at build time, and for them the flexibility belongs to the developers writing queries, not to anyone on the internet who finds the endpoint. Two caveats hold either way. Apollo’s automatic persisted queries are a caching feature, and its documentation notes that “APQ doesn’t provide safelisting capabilities.” And an allowlist still accepts your own operations with an attacker’s variables, so the limits on lists and cost still matter.

Federation Adds Doors

A federated graph has more ways in than the router. Every subgraph exposes two fields the router needs, _service, whose sdl field returns the subgraph’s full schema, and _entities, which resolves any entity by its key. Apollo’s guidance is that “only the router should query individual subgraphs directly,” because if a subgraph is reachable, “any client can circumvent internal resolver logic and fetch any entity data by mimicking the router” (Apollo). Every protection on the router is worth nothing if the subgraph behind it answers the internet directly. Subgraphs belong on a private network, reachable from the router and nothing else.

A client can send any operation it likes. At the edge, only JSON POSTs from checked origins get through, rate-limited by cost. The router then rejects unknown operations with trusted documents, enforces limits on depth, aliases, root fields and tokens, estimates cost before executing, and hides the schema by disabling introspection and field suggestions. Only then does the operation reach subgraphs on a private network, where _entities and _service are unreachable from outside, and finally the business layer, which checks authorization on every load and uses batched loaders and timeouts.
Where each defense sits. Every layer rejects a class of abusive operation before it can reach a resolver.

When I helped found the GraphQL Council at Allē, most of what we reviewed was naming and ownership, which types belonged to whom and what they should be called. I’d put security on the same checklist from the start. Which operations are allowed, what each one is allowed to cost, where each object’s authorization check lives, which subgraphs are reachable, and which limits are on in warn-only mode waiting to be enforced are schema decisions as much as names are, and they’re far cheaper to make before the first public client depends on the graph than after.

If I were standing up a GraphQL API today, I’d run a current version of the server library, because the introspection fixes are recent and the limits themselves get patched. I’d turn introspection and field suggestions off in production and check that it stays off in every environment. I’d set limits on depth, aliases, root fields, directives and tokens, start them in warn-only mode, and enforce them once the logs were quiet. I’d price every query and rate-limit by cost, not by request. I’d check authorization where data is loaded, not where the request arrives, so every path to an object meets the same check. I’d accept mutations only as JSON over POST and check the origin of every subscription. I’d allowlist operations for every first-party client, and I’d keep subgraphs off the public internet. None of those take away what GraphQL is for. They decide who gets to use it.

GraphQL lets the client ask anything. Hardening decides what anything means.

Sources

  1. graphql-java, “GoodFaithIntrospection.java”
  2. graphql-js v16.9.0, “MaxIntrospectionDepthRule.ts,” June 2024
  3. NVD, CVE-2024-40094, July 30, 2024
  4. GitLab, “Patch release: GitLab 18.8.4,” February 10, 2026
  5. Escape, graphql-armor, “max-depth”
  6. OWASP, “GraphQL Cheat Sheet”
  7. Apollo, “Apollo Server API reference”
  8. GraphQL Foundation, “Security”
  9. Clairvoyance
  10. Checkmarx, “CVE-2022-37734: graphql-java Denial of Service,” March 30, 2023
  11. NVD, CVE-2024-47614, October 3, 2024
  12. Apollo, “Request Limits”
  13. GitHub Docs, “Rate limits and query limits for the GraphQL API”
  14. Shopify, “GraphQL Admin API rate limits”
  15. Apollo, “Demand Control”
  16. Apollo, “Persisted Queries”
  17. Benjie Gillam, “Trusted Documents”
  18. Apollo, “GraphOS Security Overview”
  19. Escape, “The State of GraphQL Security 2024,” July 2024
  20. HackerOne report 1618347, disclosed January 21, 2025
  21. HackerOne report 2207248 (Shopify), disclosed February 8, 2024
  22. HackerOne report 1116387 (Uber), disclosed April 29, 2021
  23. GitHub Advisory Database, CVE-2025-64173 (Apollo Router), November 2025
  24. GraphQL Foundation, “Authorization”
  25. Apollo, “Configuring CORS”
  26. GitLab, “Patch release: GitLab 19.2.4,” August 17, 2026
  27. Spring, “CVE-2026-41700: Cross-Site WebSocket Hijacking in Spring for GraphQL,” June 2026
  28. GitHub Advisory Database, CVE-2026-40762 (WPGraphQL), June 2026
  29. Sansec, “StyleSmuggler,” September 2026
  30. GitHub Advisory Database, CVE-2025-32033 (Apollo Router), April 2025