GraphQL solved a real problem. Instead of rigid REST endpoints that return too much or too little data, GraphQL lets the client specify exactly what it needs. Frontend teams love it. Mobile developers love it. API consumers of all kinds appreciate the flexibility.
Attackers also appreciate the flexibility.
The same features that make GraphQL powerful for developers make it powerful for adversaries. A query language that lets clients traverse your data graph, specify arbitrary field selections, and nest queries to arbitrary depth is a query language that gives attackers fine-grained control over how they probe your API.
Introspection: Your API Documentation for Attackers
GraphQL includes a built-in introspection system. Send a query like { __schema { types { name fields { name type { name } } } } } and the API responds with its complete schema -- every type, every field, every relationship, every argument.
In development, this is invaluable. It powers IDE autocompletion, generates documentation, and helps teams understand the API. In production, it gives attackers a complete map of your data model.
I have seen GraphQL APIs where introspection revealed internal types named things like AdminUser, DebugLog, and InternalConfig. These types were not meant to be public, but introspection made them discoverable.
The fix: Disable introspection in production. Every major GraphQL framework supports this. Apollo Server, Yoga, and graphql-ruby all have configuration options to disable introspection queries in non-development environments.
The caveat: Disabling introspection is not security through obscurity. It is reducing your attack surface. Attackers can still discover your schema through error messages, field suggestion features, and brute-force queries. But there is no reason to hand them a complete map.
Query Complexity Attacks
The most distinctive security risk in GraphQL is query complexity abuse. Consider a schema where a User has Posts, each Post has Comments, each Comment has an Author (User), and each Author has Posts. A malicious client can craft a deeply nested query that follows these circular references:
query {
users {
posts {
comments {
author {
posts {
comments {
author {
posts { ... }
}
}
}
}
}
}
}
}
Each level of nesting multiplies the work the server must do. A query that looks small in terms of text can generate millions of database operations and consume enormous server resources. This is a denial of service attack that bypasses traditional rate limiting because it is a single HTTP request.
Defenses Against Query Complexity
Query depth limiting: Set a maximum nesting depth. Most applications do not need queries deeper than 5-7 levels. Tools like graphql-depth-limit make this easy.
Query complexity analysis: Assign a cost to each field based on the expected work to resolve it. A scalar field might cost 1 point. A field that triggers a database query might cost 10. A field that returns a list might cost 10 multiplied by the expected number of items. Reject queries that exceed a complexity threshold.
Pagination enforcement: Never allow unbounded list queries. Require first/last arguments with reasonable maximums. A query for all users with all their posts is an unacceptable resource consumption regardless of nesting depth.
Query allowlisting: In production, only allow specific pre-approved queries. This is the most secure approach but also the most restrictive. It works well when you control all clients (your own web and mobile apps) but breaks down when you expose the API to third parties.
Authorization: The Field-Level Challenge
REST APIs have a natural authorization boundary at the endpoint level. Access to GET /admin/users can be restricted to admin roles. GraphQL collapses all of this into a single endpoint, usually POST /graphql. Authorization must happen at the field and resolver level.
This creates a much larger authorization surface. Every field in your schema needs an authorization check, and the interactions between fields create complex permission scenarios.
Consider a schema with a User type that has public fields (name, avatar) and private fields (email, phone, address). In a REST API, you might have a public profile endpoint and a private account endpoint. In GraphQL, both are fields on the same type, and authorization must be checked per-field, per-request, considering who is asking and what context the field appears in.
Common mistakes include:
- Relay on client-side field selection: Trusting that the client will only request fields it is authorized to see
- Missing authorization on nested types: Checking authorization on the top-level query but not on nested fields
- Inconsistent authorization across entry points: A field that is protected when accessed through one query path but exposed through another
Injection Vulnerabilities
GraphQL does not magically prevent injection attacks. Arguments passed through GraphQL variables still end up in database queries, external API calls, and other places where injection is possible.
The GraphQL type system provides some protection by enforcing types on arguments. An argument defined as Int cannot receive a string containing SQL injection payload. But String arguments are just as vulnerable to injection as any other string input.
Particular risk areas include:
- Search and filter arguments: These often end up directly in database WHERE clauses
- Custom scalar types: Types like JSON or DateTime that accept string input and parse it on the server
- Directive arguments: Custom directives that accept user input
Batching Attacks
Many GraphQL implementations support query batching, where multiple queries are sent in a single HTTP request as an array. This feature, intended to reduce network overhead, can be abused for:
Brute force attacks: Send hundreds of login mutation attempts in a single batched request, bypassing per-request rate limits.
Object enumeration: Query for hundreds of different IDs in a single request to enumerate valid objects.
Token guessing: Submit many different reset tokens or verification codes in one batch.
Rate limiting for GraphQL must account for batching. Limit the number of operations per batch, and apply rate limits per operation rather than per HTTP request.
Supply Chain Risks in GraphQL Libraries
The GraphQL ecosystem has its own dependency landscape. Your GraphQL server depends on a schema parser, a query executor, validation logic, and often additional libraries for subscriptions, file uploads, caching, and federation.
Vulnerabilities in these libraries can have severe consequences. A parsing vulnerability could allow denial of service. A validation bypass could allow queries that should be rejected. A vulnerability in the execution engine could allow unauthorized data access.
How Safeguard.sh Helps
Safeguard tracks the entire dependency tree of your GraphQL implementation, from the core execution engine to resolver libraries, schema stitching tools, and middleware. When vulnerabilities are discovered in graphql-js, Apollo Server, or any GraphQL ecosystem package, Safeguard identifies which of your services are affected and helps you prioritize patches based on actual exposure. With comprehensive SBOM generation, you maintain full visibility into the supply chain behind your GraphQL APIs.