GraphQL changed the way I think about API testing. With REST, I’m usually mapping out dozens of separate endpoints. With GraphQL, I’m often looking at a single endpoint — usually /graphql — that hides an entire graph of queries, mutations, and subscriptions behind it. That single point of entry makes GraphQL testing both simpler and more complex at the same time. In this guide, I’ll walk through exactly how I test GraphQL APIs for security issues, from reconnaissance to advanced attack techniques.
Why GraphQL Needs Its Own Testing Playbook
GraphQL isn’t just “REST with a different syntax.” It introduces its own set of risks:
- A single endpoint can expose the entire data schema if introspection isn’t disabled.
- Clients can request deeply nested or overly broad data in a single query, leading to excessive data exposure or denial of service.
- Traditional REST-focused scanners often don’t understand GraphQL’s query language, so automated tools miss a lot.
- Authorization logic is easy to get wrong because it has to be enforced at the field level, not just the endpoint level.
Step 1: Reconnaissance — Finding the GraphQL Endpoint
GraphQL endpoints are usually predictable. I always check these common paths first:
/graphql
/graphql/console
/graphiql
/api/graphql
/v1/graphql
/query
I also look for GraphQL usage indirectly through:
- JavaScript bundle analysis (searching for
graphql,apollo,relay, orurqlreferences) - Network requests in browser dev tools while using the application normally
- Response headers like
X-GraphQL-Traceor Apollo-specific headers
Step 2: Testing for Introspection
Introspection is GraphQL’s built-in feature that lets a client ask the API to describe its entire schema — every type, query, mutation, and field available. It’s incredibly useful for developers, and incredibly useful for attackers if left enabled in production.
I test this with a standard introspection query:
query IntrospectionQuery {
__schema {
types {
name
fields {
name
type {
name
}
}
}
}
}
If this returns a full schema dump, I now have a complete map of every query, mutation, and field the API supports — including ones that might not be used by the official frontend at all (leftover admin mutations, deprecated fields, internal-only queries).
Even if introspection is disabled, I don’t stop there. I try:
- Alternate introspection queries targeting just
__typefor specific guessed type names - Checking for cached schema files exposed via static assets
- Field name guessing using wordlists based on common naming conventions (
getUser,deleteUser,adminPanel, etc.) - Tools like
clairvoyanceorgraphql-copthat can partially reconstruct a schema even without introspection, by brute-forcing field and type names against error messages
Step 3: Testing Authorization at the Field Level
This is where GraphQL testing diverges most sharply from REST. In REST, authorization is usually checked once per endpoint. In GraphQL, a single query can touch dozens of fields and nested types, and every one of them needs its own authorization check.
I specifically test:
- Object-level checks per field — can I query a field on an object I don’t own by supplying a different ID?
- Nested object traversal — GraphQL lets you query relationships (
user { orders { payment { cardNumber } } }). I test whether deeply nested fields skip authorization checks that the top-level field enforces. - Mutation-level authorization — does every mutation check permissions, or did the developer only secure the “obvious” ones like
deleteUserwhile forgettingupdateUserRole? - Batch query abuse — GraphQL allows multiple queries/mutations in a single request using aliases. I test if rate limiting or authorization checks are bypassed when the same sensitive query is aliased and repeated multiple times in one request:
query {
a: resetPassword(email: "victim1@example.com")
b: resetPassword(email: "victim2@example.com")
c: resetPassword(email: "victim3@example.com")
}
Step 4: Testing for Denial of Service via Query Complexity
GraphQL’s flexibility is also its biggest DoS risk. I always test:
Deeply Nested Queries
query {
user {
friends {
friends {
friends {
friends {
friends {
name
}
}
}
}
}
}
}
If the API doesn’t limit query depth, this kind of nesting can cause exponential resource consumption on the backend.
Query Batching Abuse Sending hundreds of queries or mutations in a single HTTP request to see if per-request rate limiting is bypassed entirely, since many APIs rate-limit by HTTP request count, not by query count within a request.
Alias-Based Amplification Using many aliases to request the same expensive field dozens of times in one request, multiplying the backend load far beyond what the rate limiter expects.
query {
a: expensiveSearch(term: "test") { id }
b: expensiveSearch(term: "test") { id }
c: expensiveSearch(term: "test") { id }
...
}
Circular Fragment Queries Testing whether the server properly rejects fragments that reference each other in a loop, which can cause the query parser itself to hang or crash.
Step 5: Testing for Injection Through GraphQL
GraphQL doesn’t eliminate injection risks — it just moves where I look for them:
- SQL/NoSQL injection in resolver arguments that get passed directly into database queries
- OS command injection in fields that trigger backend shell operations (rare, but I’ve seen it in file-processing mutations)
- SSRF through fields that accept URLs (like webhook registration mutations or image-fetch-by-URL mutations)
I test these the same way I would in REST — by injecting payloads into every argument — but I have to remember that GraphQL arguments are typed, so I need to match the expected type (string, int, enum) while still trying to break out of it.
Step 6: Testing for Excessive Data Exposure
Because GraphQL responses are shaped by exactly what the client requests, it’s tempting for developers to expose broad object types and let the frontend “just pick what it needs.” I test this by requesting every field on a returned type, especially ones with sensitive names like passwordHash, internalNotes, ssn, or apiKey, to see if the backend actually restricts field-level access or just trusts that the official frontend won’t ask for them.
Step 7: Testing Subscriptions (WebSocket-Based GraphQL)
Subscriptions use a persistent WebSocket connection, and I test these separately because:
- Authorization is often only checked at connection time, not per-message, meaning a subscription established with valid credentials might keep delivering data after a token expires or a permission is revoked.
- I test whether subscribing to another user’s data stream (like
orderUpdates(userId: "victim-id")) is properly authorized. - I check whether the WebSocket handshake itself validates the origin header to prevent cross-site WebSocket hijacking.
Step 8: Testing Error Messages for Information Disclosure
GraphQL error responses are notoriously chatty by default. I check whether stack traces, internal file paths, or database error details leak through in the errors array of a response, especially when I intentionally send malformed queries or type-mismatched arguments.
Tools I Use for GraphQL Testing
- GraphQL Playground / GraphiQL — for manual exploration when introspection is enabled
- InQL — a Burp Suite extension purpose-built for GraphQL testing
- graphql-cop — automated security checks against common GraphQL misconfigurations
- Clairvoyance — schema reconstruction when introspection is disabled
- Altair GraphQL Client — for crafting and organizing complex queries during manual testing
Common GraphQL Vulnerabilities I Keep Finding
- Introspection left enabled in production, handing over the full schema.
- Missing field-level authorization on nested objects.
- No query depth or complexity limiting, opening the door to DoS.
- Batch queries bypassing per-request rate limits.
- Verbose error messages leaking internal implementation details.
- Subscriptions that don’t re-validate authorization after the initial connection.
How I Recommend Fixing GraphQL Security Issues
- Disable introspection in production environments.
- Implement query depth limiting and query complexity scoring (assigning a “cost” to each field and rejecting queries above a threshold).
- Enforce authorization at the resolver level for every field, not just at the top-level query.
- Rate-limit based on query complexity, not just request count.
- Sanitize and generalize error messages before returning them to clients.
- Re-validate authorization on every message in long-lived subscription connections, not just at connection time.
- Use allow-listed persisted queries in production instead of accepting arbitrary ad-hoc queries from clients.
Final Thoughts
GraphQL testing forces me to think in terms of the entire graph rather than isolated endpoints, and that shift in mindset is exactly what makes it interesting. The flexibility that makes GraphQL powerful for developers is the same flexibility that creates room for authorization gaps, DoS vectors, and data exposure if it isn’t tested carefully.
