Skip to content

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.

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 POST with Content-Type: application/json and a body shaped {"query": "..."}.
  • Errors returned as {"errors": [{"message": "...", "locations": [...] }]} even on 200 OK — GraphQL typically returns 200 and puts problems in an errors array.
  • A telltale response to a malformed query, e.g. "Must provide query string" or "Cannot query field X on type Y".
Terminal window
# 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" } }

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.

Terminal window
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 read data; mutations change it. Both go to the same endpoint; only the operation keyword differs. Test mutations only on data you own.

Terminal window
# 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 .

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:

Terminal window
# 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:

introspection.graphql
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:

Terminal window
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.json

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.
Terminal window
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.
Terminal window
# 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/graphql

Testing 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:

Terminal window
# 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.

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.

Terminal window
# 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):

Terminal window
curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \
--data-raw '[{"query":"{ __typename }"},{"query":"{ shop { name } }"}]' | jq .

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.

Terminal window
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.
  • extensions often carry an error code, a cost breakdown (query complexity budget), or debug info. Shopify’s Admin API, for example, returns a query cost in extensions.cost — useful to understand throttling.
Terminal window
curl -sS https://api.example.com/graphql -H 'Content-Type: application/json' \
--data-raw '{"query":"{ product(id:1) { id } }"}' \
| jq '.errors[]?.message, .extensions?.cost'

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.
Terminal window
# Detect a GraphQL endpoint, then fingerprint the engine:
graphw00f -d -t https://api.example.com/graphql # detect
graphw00f -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.
  • GraphQL is one POST endpoint speaking {query, variables, operationName}; errors come back in an errors array, often with 200 OK.
  • Introspection dumps the entire schema when enabled; save it to schema.json and mine it with jq.
  • 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.