API deployments grew 167% year-over-year according to the 2025 State of APIs report, and GraphQL is claiming an outsized share of that growth.[1] What was once a niche alternative championed by Facebook has become the default API architecture for companies like GitHub, Shopify, Stripe, PayPal, Twitter, and a rapidly expanding list of enterprises that decided REST endpoints were not keeping up with the complexity of their frontend requirements.
Yet the penetration testing industry has not caught up. The vast majority of API security assessments still follow REST-centric methodologies: enumerate endpoints, test each HTTP verb, fuzz parameters, check for BOLA on sequential IDs, validate JWT handling, and move on. When the target API is GraphQL, these testers interact with a single POST /graphql endpoint, run a handful of queries pulled from the client-side JavaScript, and call it tested.
That approach misses entire categories of vulnerabilities that are unique to GraphQL's query language, type system, and resolver architecture. In our engagements, GraphQL APIs produce 2-3x more critical findings than equivalent REST APIs, not because GraphQL is inherently insecure, but because the attack surface is fundamentally different and most security teams are not testing for it.
Why REST-Centric Testing Fails on GraphQL
REST and GraphQL differ in ways that break core assumptions of traditional API pentesting.
In REST, the server defines what data each endpoint returns. GET /api/users/123 returns a fixed JSON structure determined by backend code. The client has no say in what fields come back. This means the attack surface is the set of endpoints and HTTP methods, and testing means hitting each endpoint with manipulated inputs.
In GraphQL, the client decides. A single endpoint accepts an arbitrarily complex query that can traverse relationships, batch multiple operations, nest to arbitrary depth, and select any combination of fields from the schema. The server's job is to resolve whatever the client asks for. This inverts the security model. Instead of securing 50 endpoints with fixed behavior, you are securing a query language that can express millions of unique requests against your data graph.
Key distinction: REST security is about protecting endpoints. GraphQL security is about protecting a query language. Every resolver, every field, every relationship path, and every combination of operations is a potential attack vector. A pentester who only tests the queries they find in the frontend JavaScript is testing less than 1% of what the API actually accepts.
This fundamental difference means that REST-centric testing misses these GraphQL-specific attack classes:
- Introspection-based reconnaissance that reveals the entire API schema in a single query
- Query depth and complexity attacks that cause denial of service through legitimate-looking queries
- Batching and aliasing attacks that bypass rate limiting and brute-force protections
- Field-level authorization failures where individual resolvers lack access control checks
- N+1 query exploitation that weaponizes database performance antipatterns
- Mutation-based mass assignment through GraphQL's typed input system
- Subscription channel abuse for real-time data exfiltration
Introspection Queries: The Complete Reconnaissance Tool
GraphQL introspection is a built-in feature that allows any client to query the schema itself. A single introspection query returns every type, field, argument, mutation, subscription, enum, and relationship in your API. This is not a vulnerability being exploited. It is a feature working as designed.
# Full introspection query - returns your entire API schema
{
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
name
kind
fields {
name
args {
name
type { name kind ofType { name kind } }
}
type { name kind ofType { name kind } }
}
}
}
}
When introspection is enabled in production (which we find in approximately 60% of GraphQL deployments we test), the attacker receives a complete map of the API before writing a single exploit. We routinely discover hidden mutations like adminCreateUser, internalTransferFunds, debugResetPassword, and exportAllCustomerData that are not referenced anywhere in the client application but are fully accessible through the GraphQL endpoint.[2]
Beyond Disabling Introspection
Many teams disable introspection and consider the attack surface closed. It is not. GraphQL implementations leak schema information through two additional channels.
Field suggestions reveal field names through error messages when you send queries with misspelled fields:
# Request
query { user { emai } }
# Response - leaks the actual field name
{
"errors": [{
"message": "Cannot query field 'emai' on type 'User'. Did you mean 'email' or 'emailVerified'?"
}]
}
The tool Clairvoyance automates this process, iterating through wordlists and collecting suggestions until it reconstructs 70-90% of the schema without introspection access.[3] Combined with field names extracted from client-side JavaScript bundles, an attacker can typically recover the full schema within minutes even on hardened deployments.
Query Depth and Complexity Attacks
GraphQL's relational query model allows clients to traverse relationships to arbitrary depth. When your schema defines circular relationships (which nearly every real-world schema does), attackers can construct queries that grow exponentially in resolution cost.
Consider a schema where users have posts, posts have comments, and comments have an author (a user). This creates a cycle: User -> Posts -> Comments -> Author -> Posts -> Comments -> Author, repeating infinitely:
# Exponential resource consumption through circular relationship traversal
query DepthAttack {
users(first: 100) {
posts(first: 100) {
comments(first: 100) {
author {
posts(first: 100) {
comments(first: 100) {
author {
posts(first: 100) {
comments(first: 100) {
author {
email
}
}
}
}
}
}
}
}
}
}
}
This single query requests 100 users, each with 100 posts, each with 100 comments, each traversed 3 levels deep. The theoretical resolution cost is 100^7 = 100 trillion resolver invocations. Even with pagination limits, a depth of 8-10 levels with 10-20 items per level can generate millions of database queries and consume all available server memory.
Real-world impact: In a recent engagement, we crashed a production GraphQL API with a single query that nested 12 levels deep through a user-organization-team-member-project-task-assignee cycle. The server ran out of memory in 4.2 seconds. There was no query depth limit, no complexity analysis, and no timeout on query execution. The application had passed its previous pentest with zero findings on the API.
Complexity Analysis vs. Depth Limiting
Simple depth limiting (rejecting queries beyond N levels) is a start but insufficient. An attacker can stay within the depth limit while still generating massive cost through wide queries at each level. The correct defense is query complexity analysis, where each field has an assigned cost multiplied by its pagination arguments:
# This query is only 3 levels deep but has enormous complexity
query WideAttack {
users(first: 1000) { # cost: 1000
posts(first: 1000) { # cost: 1000 * 1000 = 1,000,000
title
body
comments(first: 1000) { # cost: 1000 * 1000 * 1000 = 1,000,000,000
text
}
}
}
}
# Total complexity: ~1,001,001,000 (exceeds any reasonable threshold)
Libraries like graphql-query-complexity for Node.js and graphql-ruby's built-in complexity analyzer allow you to assign cost values to fields and reject queries exceeding a threshold before execution begins.[4]
Batching Attacks: Bypassing Rate Limits
GraphQL supports two forms of operation batching that most rate limiting implementations do not account for: array batching and alias batching. Both allow an attacker to execute hundreds or thousands of operations in a single HTTP request, completely bypassing per-request rate limits.
Array Batching
Most GraphQL servers accept an array of operations in a single POST request:
# Single HTTP request containing 1000 login attempts
POST /graphql
Content-Type: application/json
[
{"query": "mutation { login(email: \"[email protected]\", password: \"password1\") { token } }"},
{"query": "mutation { login(email: \"[email protected]\", password: \"password2\") { token } }"},
{"query": "mutation { login(email: \"[email protected]\", password: \"password3\") { token } }"},
... // 997 more attempts
]
The server processes all 1,000 login attempts as part of a single HTTP request. If rate limiting is implemented at the HTTP level (counting requests), this counts as one request. The attacker gets 1,000 password guesses per rate limit window.
Alias Batching
Even when array batching is disabled, GraphQL aliases allow multiple invocations of the same field or mutation within a single query:
# Single query with 100 login attempts using aliases
mutation BruteForce {
attempt1: login(email: "[email protected]", password: "password1") { token }
attempt2: login(email: "[email protected]", password: "password2") { token }
attempt3: login(email: "[email protected]", password: "password3") { token }
attempt4: login(email: "[email protected]", password: "password4") { token }
# ... up to hundreds of aliases
attempt100: login(email: "[email protected]", password: "password100") { token }
}
This is a single query in a single HTTP request. Each alias executes the login mutation independently with different arguments. We have used this technique to brute-force OTP codes (typically 6 digits = 1,000,000 combinations), bypass account lockout mechanisms, and enumerate valid usernames through timing differences across aliased operations.[5]
Remediation: Rate limiting must count operations, not requests. Count each element in a batch array and each alias invocation separately. Libraries like graphql-rate-limit and graphql-shield provide resolver-level rate limiting that correctly counts per-operation regardless of batching method.
Authorization Bypass Through Field-Level Access Control Failures
This is the most impactful category of GraphQL vulnerabilities we find, and it stems from a fundamental misunderstanding of how GraphQL resolves data. In REST, authorization is typically checked once at the endpoint level. In GraphQL, each field has its own resolver, and authorization must be enforced at every resolver that accesses sensitive data.
Consider a schema with a User type that has both public and sensitive fields:
type User {
id: ID!
name: String! # Public
avatar: String # Public
email: String! # Sensitive - only self or admin
ssn: String # Highly sensitive - only self
salary: Float # Highly sensitive - only HR/admin
internalNotes: String # Internal only - admin
}
The developer correctly adds authorization to the user query resolver, checking that the requester can access the target user. But the individual field resolvers for email, ssn, salary, and internalNotes inherit the parent resolver's context without additional checks. Since the parent allowed access (the requester can see the user's public profile), every field on that user is returned.
Relationship Traversal Bypass
The problem compounds through relationships. Even if direct access to sensitive fields is restricted, attackers can reach the same data through alternative graph paths:
# Direct query - properly blocked
query { user(id: "other-user") { salary } }
# Response: "Not authorized to access this user's salary"
# Relationship traversal - bypasses authorization
query {
project(id: "shared-project") {
team {
members {
salary # Same field, accessed through a different path
ssn # Resolver doesn't re-check authorization
}
}
}
}
The project query checks that the requester is a project member. The team resolver returns the project's team. The members resolver returns team members. At no point does the salary or ssn field resolver verify that the requester is authorized to see this specific data for this specific user. Authorization was checked at the entry point but not propagated to nested resolvers.[6]
In one engagement, we accessed the salary data of every employee at a 500-person company by traversing through a shared project that a low-privilege guest account had been invited to. The project query was authorized, and nothing downstream re-validated access.
N+1 Query Abuse for Denial of Service
The N+1 query problem is a well-known performance antipattern in GraphQL, but it is also a security vulnerability when intentionally exploited. When a resolver fetches related data without batching (using DataLoader or equivalent), each parent record triggers a separate database query for its children.
# This query triggers 1 + N + N*M + N*M*K database queries
query N1Attack {
organizations { # 1 query: SELECT * FROM organizations
employees { # N queries: SELECT * FROM employees WHERE org_id = ?
projects { # N*M queries: SELECT * FROM projects WHERE employee_id = ?
tasks { # N*M*K queries: SELECT * FROM tasks WHERE project_id = ?
title
assignee { name } # N*M*K*L queries: SELECT * FROM users WHERE id = ?
}
}
}
}
}
With 10 organizations, 50 employees each, 10 projects each, and 20 tasks each, this generates 1 + 10 + 500 + 5,000 + 100,000 = 105,511 database queries from a single GraphQL request. Without DataLoader batching (which we find absent in roughly 40% of GraphQL deployments), each of those is a separate round-trip to the database.
Even with DataLoader, attackers can construct queries that maximize unique cache keys to force individual lookups. The combination of N+1 exploitation with fragment spreading creates particularly effective resource exhaustion:
# Fragment spreading amplifies resolver execution
fragment UserFields on User {
posts { author { posts { author { name } } } }
}
query Amplified {
users(first: 50) {
...UserFields
friends(first: 50) {
...UserFields
}
}
}
Mutation-Based Attacks: Mass Assignment via GraphQL Inputs
GraphQL's typed input system creates a unique mass assignment attack surface. Unlike REST APIs where unexpected JSON fields are typically ignored by most frameworks, GraphQL mutations with permissive input types can accept and process fields that the developer did not intend to be user-modifiable.
# The schema defines an UpdateUser input
input UpdateUserInput {
name: String
email: String
avatar: String
role: UserRole # Should only be settable by admins
isVerified: Boolean # Should only be settable by system
creditBalance: Float # Should only be modified through transactions
}
# Attacker sends a mutation including restricted fields
mutation {
updateUser(input: {
name: "Normal Update"
role: ADMIN
isVerified: true
creditBalance: 999999.99
}) {
id
role
isVerified
creditBalance
}
}
If the mutation resolver applies the entire input object to the database update without filtering, the attacker has escalated to admin, verified their account, and given themselves unlimited credit in a single request. The GraphQL type system makes this attack easier than in REST because the attacker can discover all available input fields through introspection and knows exactly what types and values each field accepts.[7]
Nested Mutation Injection
GraphQL mutations with nested input types are particularly dangerous because the nested objects often map directly to related database records:
mutation {
createOrder(input: {
items: [{ productId: "123", quantity: 1 }]
payment: {
amount: 0.01 # Attacker sets their own price
currency: "USD"
method: "credit_card"
}
shipping: {
priority: "express" # Gets express shipping
cost: 0.00 # At zero cost
}
}) {
id
total
}
}
When mutation resolvers trust input values for fields that should be server-calculated (prices, shipping costs, tax amounts), the attack is trivially exploitable. We find this pattern frequently in e-commerce GraphQL APIs where the frontend sends calculated totals and the backend does not recalculate.
Subscription Abuse for Data Exfiltration
GraphQL subscriptions use WebSocket connections to push real-time data to clients. This introduces attack vectors that do not exist in request-response APIs. A subscription, once established, continuously streams data without requiring additional authentication checks per message.
# Subscribe to all order events - may leak other users' orders
subscription {
orderUpdated {
id
customer {
name
email
address
}
items { name quantity price }
total
paymentMethod { last4 type }
}
}
Common subscription authorization failures we exploit:
- Missing subscription-level auth: Authentication is checked when the WebSocket connects but not when individual subscriptions are created. An authenticated low-privilege user subscribes to admin-only event streams.
- Missing event-level filtering: The subscription fires for all events matching the type, not just events the subscriber is authorized to see. A user subscribed to
orderUpdatedreceives events for every order in the system, not just their own. - Token expiry not enforced: JWT tokens are validated at WebSocket connection time. If the token expires while the connection is open, the subscription continues streaming data. An attacker with a short-lived token maintains a subscription indefinitely.
- Connection hijacking: WebSocket upgrade requests may not enforce CORS, allowing cross-origin pages to establish subscription connections using the victim's cookies.
Testing approach: Establish subscriptions with different privilege levels and monitor what events are received. Use multiple WebSocket clients to test connection limits. Verify that token expiry terminates active subscriptions. Test subscription creation for operations the user should not have access to.
A Complete GraphQL Pentesting Methodology
Based on hundreds of GraphQL assessments, here is the methodology we follow at Lorikeet Security. Each phase builds on the previous one.
Phase 1: Schema Analysis and Reconnaissance
- Identify the GraphQL endpoint (common paths:
/graphql,/api/graphql,/gql,/query) - Test introspection (authenticated and unauthenticated)
- If introspection is disabled, run Clairvoyance for schema reconstruction via field suggestions
- Extract queries from client-side JavaScript bundles and mobile app binaries
- Run graphql-cop for automated baseline assessment
- Visualize the schema in GraphQL Voyager to map relationship cycles and sensitive fields
- Catalog all mutations, especially those not referenced by the frontend
Phase 2: Authorization Testing Per Field
- Test every query and mutation with: no auth, low-privilege auth, cross-tenant auth, expired tokens
- Test every sensitive field through direct access AND relationship traversal paths
- Map authorization boundaries: which fields are protected, which are not, and where do protections have gaps
- Test mutations for mass assignment by including every field from the input type
- Test subscription authorization for each event type and privilege level
Phase 3: Rate Limiting and DoS Analysis
- Test array batching with 10, 100, and 1000 operations per request
- Test alias batching on authentication mutations (login, OTP verification, password reset)
- Craft deep nesting queries targeting circular relationships
- Test wide queries that maximize resolver execution at each level
- Measure query execution time to identify N+1 vulnerable resolvers
- Verify query complexity limits are enforced before execution (not after timeout)
Phase 4: Query Complexity Analysis
- Determine maximum query depth allowed
- Test whether complexity is calculated statically (before execution) or dynamically (during execution)
- Craft queries that stay under complexity limits but maximize actual resource consumption
- Test fragment spreading for complexity amplification
- Verify that persisted queries or query allowlisting is enforced, if present
Tools for GraphQL Penetration Testing
Effective GraphQL pentesting requires purpose-built tooling. Here is the toolkit we use in every engagement.
Burp Suite with GraphQL Extensions
Burp Suite remains the foundation, but it needs GraphQL-specific extensions to be effective. The InQL extension by Doyensec performs introspection analysis, generates queries and mutations for every schema operation, and includes an automated scanner for common GraphQL vulnerabilities. The GraphQL Raider extension adds GraphQL-aware editing and repeating capabilities to Burp's core tools.[8]
graphql-cop
A Python-based security auditor that tests for introspection disclosure, field suggestion leakage, batching support, query depth limits, alias-based attacks, GET-based query execution, and several other GraphQL-specific issues in seconds:[9]
# Run a comprehensive baseline scan
python3 graphql-cop.py -t https://target.com/graphql
# Example output:
# [HIGH] Introspection enabled - full schema accessible
# [HIGH] Batch queries supported - no operation limit detected
# [MEDIUM] Field suggestions enabled - schema leakage possible
# [MEDIUM] No query depth limit detected
# [LOW] GET method queries supported - potential CSRF risk
# [INFO] GraphQL implementation: Apollo Server v4.x
Clairvoyance
When introspection is disabled, Clairvoyance reconstructs the schema through systematic field suggestion brute-forcing. Feed it a wordlist of common field names and it iteratively discovers types, fields, and arguments. Essential for testing hardened GraphQL endpoints.[3]
GraphQL Voyager
Visualizes the schema as an interactive graph diagram, making it easy to spot circular relationships (for depth attacks), identify sensitive fields on shared types (for authorization testing), and map the complete relationship graph that an attacker can traverse.[10]
Altair GraphQL Client
A full-featured GraphQL client that supports custom headers, environments, file uploads, and subscription testing over WebSocket. Unlike GraphiQL, Altair provides complete control over authentication headers and request configuration, making it suitable for security testing workflows.
gRPC Security Testing: The Next Frontier
While GraphQL dominates the client-facing API layer, gRPC is quietly becoming the backbone of internal service communication. Microservice architectures increasingly use gRPC for its performance benefits: binary serialization with Protocol Buffers, HTTP/2 multiplexing, bidirectional streaming, and strong typing from .proto definitions.
gRPC introduces its own security testing challenges that are even less understood than GraphQL's:
- Binary protocol: gRPC uses Protocol Buffers, a binary serialization format that is not human-readable in proxy tools. Standard Burp Suite cannot inspect or modify gRPC traffic without specialized extensions and Protocol Buffer definitions.
- HTTP/2 transport: gRPC requires HTTP/2, which has different connection handling, multiplexing, and header compression behaviors than HTTP/1.1. Many proxy configurations break when intercepting HTTP/2 traffic.
- Reflection API: Similar to GraphQL introspection, gRPC server reflection allows clients to discover all services and methods. When enabled in production, it provides a complete map of internal service APIs.
- Missing authentication on internal services: Because gRPC is often used for internal service-to-service communication, many deployments skip authentication entirely, relying on network segmentation that may be bypassed through SSRF or lateral movement.
- Stream abuse: gRPC bidirectional streaming can be exploited similarly to GraphQL subscriptions, holding connections open and consuming server resources.
Tools like grpcurl, grpcui, and the gRPC Burp extension are beginning to address the tooling gap, but gRPC security testing remains a specialized skill that few pentesting firms offer. As enterprises migrate more internal APIs to gRPC, this gap will become a significant blind spot.[11]
Looking ahead: The API landscape is fragmenting. REST, GraphQL, gRPC, WebSocket APIs, and increasingly, tRPC and server-sent events. Each protocol has its own attack surface and requires its own testing methodology. Security teams that only test REST are falling further behind the architectures their organizations actually deploy.
Building a GraphQL Security Program
Testing is essential but not sufficient. Organizations with mature GraphQL deployments need to build security into the development lifecycle.
- Enforce persisted queries in production. Also called trusted documents. The frontend team registers approved queries during build. The API rejects any query not in the allowlist. This eliminates introspection abuse, batching attacks, depth attacks, and arbitrary query execution in a single control.
- Implement resolver-level authorization. Use a library like graphql-shield to define authorization rules per field. Make the default deny: every field requires an explicit permission grant.
- Set query complexity budgets. Assign cost values to every field and connection. Reject queries exceeding the budget before execution. Review costs as the schema evolves.
- Count operations, not requests. Rate limiting must account for batching and aliasing. Every operation in a batch and every alias in a query counts as a separate invocation against the rate limit.
- Audit subscription lifecycle. Re-validate authorization on every subscription event, not just at connection establishment. Terminate subscriptions when tokens expire. Enforce per-connection subscription limits.
- Monitor query patterns. Log all queries with their complexity scores. Alert on queries that approach complexity limits, unusual field access patterns, and introspection attempts.
GraphQL is not going away. Its developer experience advantages are real, and the architecture is well-suited for complex applications with diverse frontend requirements. But its security model is fundamentally different from REST, and organizations that apply REST security patterns to GraphQL deployments are leaving critical vulnerabilities undiscovered.
The 167% growth in API deployments means the attack surface is expanding faster than most security programs can keep up. If your organization runs GraphQL in production, and statistically there is a growing chance it does, you need pentesters who understand the query language, the resolver architecture, and the specific attack classes that only exist in this paradigm.
Sources
- Postman, "2025 State of APIs Report," https://www.postman.com/state-of-api/
- OWASP, "GraphQL - OWASP Cheat Sheet Series," https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html
- Nikita Stupin, "Clairvoyance - Obtain GraphQL API Schema Despite Disabled Introspection," https://github.com/nikitastupin/clairvoyance
- Ivan Goncharov, "graphql-query-complexity," https://github.com/slicknode/graphql-query-complexity
- Assetnote, "Exploiting GraphQL," https://blog.assetnote.io/2021/08/29/exploiting-graphql/
- Apollo GraphQL, "Authorization in GraphQL," https://www.apollographql.com/docs/apollo-server/security/authentication/
- PortSwigger, "GraphQL API Vulnerabilities," https://portswigger.net/web-security/graphql
- Doyensec, "InQL - Introspection GraphQL Scanner," https://github.com/doyensec/inql
- dolevf, "graphql-cop - Security Auditor Utility for GraphQL APIs," https://github.com/dolevf/graphql-cop
- GraphQL Voyager, "Represent any GraphQL API as an Interactive Graph," https://github.com/graphql-kit/graphql-voyager
- gRPC Authors, "gRPC Server Reflection Tutorial," https://grpc.io/docs/guides/reflection/
Is Your GraphQL API Actually Tested for GraphQL Vulnerabilities?
Most API pentests follow REST methodologies and miss GraphQL-specific attack vectors entirely. Our team tests for introspection leakage, batching abuse, field-level authorization bypass, query complexity attacks, and subscription abuse specific to your GraphQL architecture.
Book a Consultation View Pricing