Skip to content

GraphQL Attacks

GraphQL exposes one endpoint (usually /graphql) and a typed schema the client queries against. That design shifts the attack surface: instead of many REST routes, you have one endpoint, a self-describing schema, and a resolver layer where authorization is easy to get wrong. This page covers the GraphQL-specific bugs and how to test them lightly. Everything is framed for your own dev store — Shopify exposes a large Admin GraphQL API, and it’s the perfect place to practice without touching anyone else’s data. See the Shopify program.

Talking to a GraphQL endpoint from the terminal

Section titled “Talking to a GraphQL endpoint from the terminal”

A GraphQL request is just an HTTP POST with a JSON body containing a query string. curl is all you need:

Terminal window
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"query":"query { shop { name } }"}' | jq .

Variables go in a separate variables object, which keeps payloads clean:

Terminal window
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"query($id: ID!){ product(id:$id){ title } }","variables":{"id":"gid://shopify/Product/1"}}' | jq .

Introspection exposure and info disclosure

Section titled “Introspection exposure and info disclosure”

GraphQL can describe its own schema via the introspection query — every type, field, and argument. It’s great for developers and a gift for testers. If introspection is enabled on a production API that shouldn’t expose it, that’s information disclosure on its own and a map for everything else.

Terminal window
# Is introspection enabled? Ask for the type names.
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ __schema { types { name } } }"}' | jq '.data.__schema.types | length'
# A number = introspection is on. An error = it's disabled (see recon below).

The tool graphql-cog/clairvoyance families and IDEs consume introspection to give you a full schema. Even without tools, __type(name: "Query") and __schema reveal the shape you need. Report enabled introspection as a finding and use it to guide the higher-severity tests below.

GraphQL’s resolvers each fetch data, and authorization must be enforced at every resolver. Two levels fail:

  • Object-level — you can query an object that isn’t yours (GraphQL’s flavor of IDOR/BOLA). GraphQL often uses global IDs like gid://shopify/Order/1052 or base64-encoded node IDs; decode, change, and re-request.
  • Field-level — you’re allowed the object but can read a sensitive field on it that should be restricted (an admin-only email, costPrice, or internalNote).

The node(id: ...) pattern is the classic object-level test: many schemas expose a generic node field that fetches any object by global ID, which can bypass per-type checks.

Terminal window
# Object-level test: can you fetch an object you shouldn't own via node()?
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN_B" \
-d '{"query":"{ node(id:\"gid://shopify/Order/1052\"){ ... on Order { name } } }"}' | jq .

Global IDs are often just base64 of Type:number — decode to understand and predict them:

Terminal window
echo 'T3JkZXI6MTA1Mg==' | base64 -d # => Order:1052

How to confirm. Same as REST IDOR: two accounts, request one’s object with the other’s token, diff the results. Field-level: request the sensitive field as a low-privilege user and see if it returns data instead of null/an error.

Impact. Cross-account or cross-tenant data access — typically the highest-severity GraphQL bug, and historically valuable on Shopify because of its multi-shop model.

Batching and aliasing (rate-limit and brute-force bypass)

Section titled “Batching and aliasing (rate-limit and brute-force bypass)”

GraphQL lets one request contain many operations, which can quietly defeat per-request rate limits.

Aliasing runs the same field many times in one query under different names:

query {
a: login(user:"me", pass:"guess1") { token }
b: login(user:"me", pass:"guess2") { token }
c: login(user:"me", pass:"guess3") { token }
}

To a rate limiter counting HTTP requests, that’s one request — but it’s three login attempts. Batching is similar: send a JSON array of operations in one HTTP call. Both turn a “1 attempt per request” limit into “N attempts per request.”

How to test lightly. You only need to prove the endpoint accepts multiplexed operations and that they each execute — a tiny alias count does that. Use two or three aliases against your own account, confirm each resolver ran, and stop. The finding is “batching/aliasing bypasses the rate limit”; you don’t need hundreds to demonstrate it.

Terminal window
# Prove aliasing executes multiple ops in one request (against YOUR account):
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ a: shop{name} b: shop{name} c: shop{name} }"}' | jq 'keys'
# => ["data"] with a,b,c all present = multiplexing works.

Note: many mature APIs (Shopify included) use a query-cost model that charges points per field, so aliasing hundreds of fields hits a cost ceiling rather than bypassing limits. Check for cost headers/errors — if present, that’s the intended defense, and finding a cheap expensive query is the real bug.

GraphQL types some inputs, but string arguments still flow into resolvers that may build SQL, NoSQL, or OS commands. GraphQL is not itself an injection defense. Treat any string argument the way you’d treat a REST parameter and apply the responsible detection from core web vulnerabilities — a single benign quote or a boolean difference, never destructive payloads.

Terminal window
# Signal only: does a lone quote in a filter argument trigger a backend error?
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ products(query:\"title:'\''\"){ edges { node { id } } } }"}' \
| jq '.errors[]?.message'

Denial of service via nested / circular queries — how to recognize it

Section titled “Denial of service via nested / circular queries — how to recognize it”

GraphQL schemas often have relationships that point back at each other: an Order has Customer, who has orders, each of which has a customer… You can nest these arbitrarily deep, and each level multiplies the work the server does. A deeply nested or circular query can blow up exponentially — that’s a real DoS class.

Recognize it without DoSing anything. You do not need to run an expensive query to find this bug. Use the schema (from introspection) to identify a cycle, then check whether the server has the standard defenses:

  • Depth limiting — a max query depth. Test with a shallow over-limit query (e.g. depth 3 when the limit is 2) and see if you get a “query is too deep” error. That single small query proves whether the control exists.
  • Query cost / complexity analysis — points per field with a ceiling. A tiny query that returns a cost error tells you the ceiling exists.
  • Pagination capsfirst/last limits on connections. Ask for first: 100000 and see if the server rejects it before executing.
Terminal window
# Recognition, not exploitation: does the server cap page size?
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ products(first: 100000){ edges { node { id } } } }"}' \
| jq '.errors[]?.message // "no cap error"'

Impact. A missing depth/cost limit lets one crafted request exhaust CPU/memory — a service-wide DoS. Because it affects everyone, programs want it reported carefully, without a live demonstration.

Turning off introspection hides the schema but rarely secures it. You can often rebuild the schema from the server’s own helpfulness:

  • Field suggestions (“did you mean?”). Many GraphQL servers, when you request a field that doesn’t exist, reply with Did you mean "createOrder"?. That leaks real field names one guess at a time.
  • Field stuffing. Probe plausible field/type names and read which ones error as “unknown field” vs which resolve — the difference maps the schema.
  • Clairvoyance-style recovery. The clairvoyance tool automates the above: it feeds a wordlist and uses suggestion messages to reconstruct the schema without introspection. Point it only at your own API.
Terminal window
# Suggestion leak: a misspelled field can reveal the real one.
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ prodcts { id } }"}' | jq '.errors[]?.message'
# => "Cannot query field \"prodcts\" ... Did you mean \"products\"?"

Recommend disabling suggestions in production as part of the fix — disabling introspection alone isn’t enough.

Mutations are GraphQL’s state-changers (create/update/delete). They deserve the same scrutiny as any write endpoint:

  • Authorization — can a low-privilege user call an admin mutation (deleteProduct, updateCustomer)? Missing checks here are direct privilege escalation.
  • Mass assignment — supply input fields the client UI never sends (isAdmin: true, ownerId: ...) and see if the resolver blindly persists them.
  • Business logic — the same price/quantity/workflow flaws from core web vulnerabilities, expressed as mutation inputs.

Test mutations only against your own objects, and prefer reversible actions (create a draft you’ll delete) over destructive ones.

GraphQL error objects are frequently over-informative. Look inside errors[] for stack traces, database messages, internal file paths, backend framework names, and resolver hints. These both disclose information and confirm other bugs (an SQL message in an error validates an injection probe). Always jq '.errors' your responses.

Terminal window
curl -s https://your-store.example/graphql \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ nonexistentRootField }"}' | jq '.errors'
  • GraphQL concentrates the attack surface: one endpoint, a self-describing schema, per-resolver auth.
  • Introspection (and, when disabled, suggestion-based recon) hands you the schema — report it and use it.
  • The highest-value bug is broken object/field-level authorization, often via node(id:) and global IDs; decode base64 IDs to understand them.
  • Batching/aliasing can bypass request-count rate limits — prove it with a tiny alias count, never a real brute-force.
  • Nested/circular-query DoS is found by checking for missing depth/cost/pagination limits with cheap probes — never by running the expensive query.
  • Scrutinize mutations for auth, mass assignment, and logic; read errors[] for disclosure.