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:
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:
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.
# 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.
Authorization gaps (the big one)
Section titled “Authorization gaps (the big one)”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/1052or 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, orinternalNote).
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.
# 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:
echo 'T3JkZXI6MTA1Mg==' | base64 -d # => Order:1052How 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.
# 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.
Injection through arguments
Section titled “Injection through arguments”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.
# 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 caps —
first/lastlimits on connections. Ask forfirst: 100000and see if the server rejects it before executing.
# 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.
Recon when introspection is disabled
Section titled “Recon when introspection is disabled”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
clairvoyancetool automates the above: it feeds a wordlist and uses suggestion messages to reconstruct the schema without introspection. Point it only at your own API.
# 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.
Mutation abuse
Section titled “Mutation abuse”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.
Verbose errors
Section titled “Verbose errors”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.
curl -s https://your-store.example/graphql \ -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \ -d '{"query":"{ nonexistentRootField }"}' | jq '.errors'Key points
Section titled “Key points”- 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.