Testing GraphQL APIs
GraphQL exposes a single endpoint and a typed schema, so the whole API is one URL that speaks a query language. That shape changes how you test it: instead of walking many routes, you interrogate one endpoint’s schema and probe authorization field by field. Shopify’s Admin API is GraphQL, which makes this directly relevant — practiced against your own development store with your own tokens.
Recognizing a GraphQL endpoint
Section titled “Recognizing a GraphQL endpoint”GraphQL usually lives at a single path and answers POST requests with JSON. Common signs:
- A path like
/graphql,/api/graphql,/graphql/v1,/query, or a GraphiQL/Playground UI. - A
POSTwithContent-Type: application/jsonand a body shaped{"query": "..."}. - Errors returned as
{"errors": [{"message": "...", "locations": [...] }]}even on200 OK— GraphQL typically returns200and puts problems in anerrorsarray. - A telltale response to a malformed query, e.g.
"Must provide query string"or"Cannot query field X on type Y".
# A minimal probe: a valid GraphQL server answers a __typename query.curl -sS https://api.example.com/graphql \ -H 'Content-Type: application/json' \ --data-raw '{"query":"{ __typename }"}' | jq .# => { "data": { "__typename": "Query" } }The request shape
Section titled “The request shape”Every GraphQL request is JSON with up to three keys: query (the operation text), variables (a JSON object of inputs), and operationName (which operation to run when the document defines several). Almost always sent as POST.
curl -sS https://api.example.com/graphql \ -H 'Content-Type: application/json' \ --data-raw '{ "query": "query Get($id: ID!) { product(id: $id) { id title } }", "variables": { "id": "42" }, "operationName": "Get" }' | jq .Queries and mutations
Section titled “Queries and mutations”Queries read data; mutations change it. Both go to the same endpoint; only the operation keyword differs. Test mutations only on data you own.
# Query (read):curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ products(first: 3) { edges { node { id title } } } }"}' | jq .
# Mutation (write) — your own store only:curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ -H 'X-Shopify-Access-Token: shpat_...' \ --data-raw '{"query":"mutation { productCreate(input:{title:\"Test\"}) { product { id } userErrors { field message } } }"}' | jq .Introspection: dumping the schema
Section titled “Introspection: dumping the schema”GraphQL’s superpower for a tester is introspection — the server can describe its own schema through the special __schema and __type meta-fields. When it’s enabled, you get every type, field, argument, and mutation without guessing.
Quick wins first:
# List every query, mutation, and subscription entry point:curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ __schema { queryType { name } mutationType { name } } }"}' | jq .
# List all type names in the schema:curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ __schema { types { name kind } } }"}' | jq -r '.data.__schema.types[].name'
# Describe one type's fields and their arguments:curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ __type(name:\"Product\") { fields { name args { name } type { name } } } }"}' | jq .The full introspection query pulls the entire schema in one request. Save it and reuse it:
query IntrospectionQuery { __schema { queryType { name } mutationType { name } subscriptionType { name } types { ...FullType } directives { name description locations args { ...InputValue } } }}fragment FullType on __Type { kind name description fields(includeDeprecated: true) { name description args { ...InputValue } type { ...TypeRef } isDeprecated deprecationReason } inputFields { ...InputValue } interfaces { ...TypeRef } enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason } possibleTypes { ...TypeRef }}fragment InputValue on __InputValue { name description type { ...TypeRef } defaultValue }fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } }}Run it by wrapping the file into a JSON body with jq, and save the result:
curl -sS https://api.example.com/graphql \ -H 'Content-Type: application/json' \ --data-raw "$(jq -n --rawfile q introspection.graphql '{query:$q}')" \ | jq . > schema.json
# Then mine the saved schema offline — e.g. list all mutation field names:jq -r '.data.__schema.types[] | select(.name=="Mutation") | .fields[].name' schema.jsonWhen introspection is disabled
Section titled “When introspection is disabled”Many production servers disable introspection (Shopify’s public Admin API, for instance, restricts it). Disabling it is only obscurity, not a fix — the schema is still reachable, just not enumerable in one shot. Two approaches:
- Field suggestions (“did you mean”). Many GraphQL servers, when you ask for a field that doesn’t exist, reply with a suggestion:
Cannot query field "usr" on type "Query". Did you mean "user"?. Those hints leak real field names one guess at a time.
curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ usr { id } }"}' | jq '.errors[].message'# => Cannot query field "usr" on type "Query". Did you mean "user"?- Clairvoyance. This is the concept behind the clairvoyance tool: it fires a wordlist of candidate field names and reconstructs the schema purely from those suggestion messages, giving you a usable schema even with introspection off.
# Rebuild a schema from field suggestions using a wordlist:clairvoyance -o schema.json -w /usr/share/seclists/Discovery/Web-Content/graphql.txt \ https://api.example.com/graphqlTesting authorization per field and object
Section titled “Testing authorization per field and object”GraphQL’s flat endpoint hides a subtlety: authorization must be enforced on every field and object, not just at the endpoint. A common bug is a server that checks you’re authenticated but lets you traverse to objects or fields you shouldn’t reach — GraphQL’s version of IDOR/BOLA.
Test it like REST IDOR, but through the query graph, using two of your own accounts:
# As account B, ask for account A's object by ID. Returning A's data = broken authorization.curl -sS https://api.example.com/graphql \ -H 'Content-Type: application/json' -H 'Authorization: Bearer <B-token>' \ --data-raw '{"query":"{ order(id:\"1001\") { id email totalPrice } }"}' | jq .Also test nested traversal: an object you are allowed to see may expose an edge to one you’re not (e.g. order { customer { paymentMethods { ... } } }). And test field-level access — a user you can read might still return an email or role field it shouldn’t. Query the sensitive field directly and see whether it comes back or errors.
Batching and aliases
Section titled “Batching and aliases”Two GraphQL features are worth understanding both as capabilities and as things a server should rate-limit.
Aliases let one query ask for the same field many times under different names. Legitimately handy; also a way to attempt many object lookups in a single request, which can sidestep naive per-request rate limits.
# Three aliased lookups in one request (your own IDs, in scope):curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ a: product(id:\"1\"){title} b: product(id:\"2\"){title} c: product(id:\"3\"){title} }"}' | jq .Batching sends an array of operations in one HTTP request (supported by many servers):
curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '[{"query":"{ __typename }"},{"query":"{ shop { name } }"}]' | jq .Variables and error-message analysis
Section titled “Variables and error-message analysis”Prefer variables over inlining values — it’s cleaner, avoids escaping bugs, and mirrors how the real client sends requests, so the server treats your request identically.
curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{ "query":"query($id: ID!){ product(id:$id){ id title } }", "variables":{"id":"gid://shopify/Product/42"} }' | jq .Read the errors array closely — it’s the richest source of signal in GraphQL:
- Field/type suggestions leak the real schema (above).
- Validation errors reveal argument names, expected types, and enum values.
extensionsoften carry an errorcode, acostbreakdown (query complexity budget), or debug info. Shopify’s Admin API, for example, returns a query cost inextensions.cost— useful to understand throttling.
curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \ --data-raw '{"query":"{ product(id:1) { id } }"}' \ | jq '.errors[]?.message, .extensions?.cost'Tooling
Section titled “Tooling”You can do everything with curl + jq, but a few tools speed up recognition and mapping:
- graphw00f — fingerprints which GraphQL server implementation is running (Apollo, graphql-ruby, Hasura, etc.) by its behavioral quirks. Knowing the engine tells you its default protections and known issues.
# Detect a GraphQL endpoint, then fingerprint the engine:graphw00f -d -t https://api.example.com/graphql # detectgraphw00f -f -t https://api.example.com/graphql # fingerprint- clairvoyance — reconstructs the schema from field suggestions when introspection is disabled (shown above).
- graphql-cli / other clients — general tooling to run operations, manage schemas, and generate typed clients; handy for exploring a schema you’ve dumped to
schema.json. - GraphiQL / Playground — if an in-scope endpoint exposes an interactive IDE, it gives you autocomplete against the live schema. Route it through your proxy so requests are logged.
Key points
Section titled “Key points”- GraphQL is one
POSTendpoint speaking{query, variables, operationName}; errors come back in anerrorsarray, often with200 OK. - Introspection dumps the entire schema when enabled; save it to
schema.jsonand mine it withjq. - When introspection is off, field-suggestion messages still leak names — the idea clairvoyance automates.
- Authorization must hold per field and per object; test IDOR/BOLA through the query graph and nested edges using two of your own accounts.
- Understand aliases and batching to spot missing rate/cost limits — identify and report them, never brute-force. Shopify’s Admin API is GraphQL: test only your own store with your own token.