GraphQL has become the API design of choice for many modern applications, offering flexible querying and reduced over-fetching. But the same flexibility that makes GraphQL powerful also introduces security challenges that do not exist in traditional REST APIs. Understanding these risks — and how to mitigate them — is essential for any team running a production GraphQL endpoint.
1. Introspection Enabled in Production
GraphQL introspection is a built-in feature that allows clients to query the schema itself — discovering every type, field, query, mutation, and their relationships. It is invaluable during development. In production, it is a detailed map of your API handed directly to attackers.
A simple introspection query reveals everything:
{ __schema { types { name fields { name type { name } } } } }
Disable introspection in production environments:
# Apollo Server (Node.js)
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
# Graphene (Python/Django)
GRAPHENE = {
'SCHEMA': 'myapp.schema.schema',
'MIDDLEWARE': [],
}
# Disable via custom validation rule or graphene-django settings
If your API is consumed by internal tooling that requires introspection, restrict it by IP or authentication header rather than disabling it globally.
2. Batching and Aliasing Attacks
GraphQL allows multiple operations in a single HTTP request through query batching. It also allows field aliasing, which lets clients repeat the same field multiple times with different names in one query. Attackers abuse both to bypass rate limiting.
# Aliasing attack — 100 login attempts in a single request
{
a1: login(username: "[email protected]", password: "pass1") { token }
a2: login(username: "[email protected]", password: "pass2") { token }
a3: login(username: "[email protected]", password: "pass3") { token }
# ... 97 more aliases
}
Mitigations:
- Disable query batching unless you explicitly need it
- Implement alias count limits (reject queries with more than N aliases)
- Apply rate limiting at the operation level, not just the HTTP request level
- Use query cost analysis (see item 3) to assign cost to repeated fields
3. Deeply Nested Queries (Query Depth DoS)
GraphQL's relational nature allows arbitrarily deep queries. If your schema allows it, an attacker can send a deeply nested query that causes your resolvers to execute thousands of database queries for a single HTTP request, crashing or severely slowing your server.
# Potentially devastating query
{
user(id: 1) {
friends {
friends {
friends {
friends {
friends { name email }
}
}
}
}
}
}
Fix with query depth limiting:
# graphql-depth-limit (Node.js)
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
validationRules: [depthLimit(5)], // Reject queries deeper than 5 levels
typeDefs,
resolvers,
});
# Strawberry (Python)
from strawberry.extensions import QueryDepthLimiter
schema = strawberry.Schema(
query=Query,
extensions=[QueryDepthLimiter(max_depth=5)]
)
Also implement query complexity analysis to assign costs to expensive operations and reject queries that exceed a total complexity budget.
4. Field-Level Authorization (IDOR in GraphQL)
GraphQL's flexible querying means that field-level authorization must be enforced in every resolver, not just at the route or query level. Missing authorization on a single field can expose data across your entire object graph.
# Wrong — authorization only at the query level
const resolvers = {
Query: {
user: (_, { id }, context) => {
if (!context.user) throw new AuthenticationError('Not authenticated');
return getUserById(id); // Any user can fetch any user's data!
}
},
User: {
// No authorization on sensitive fields
ssn: (user) => user.ssn, // Exposed to any authenticated user!
salary: (user) => user.salary, // Exposed!
}
};
# Correct — check authorization in every resolver
User: {
ssn: (user, _, context) => {
if (context.user.id !== user.id && !context.user.isAdmin) {
throw new ForbiddenError('Access denied');
}
return user.ssn;
},
}
Use a field-level authorization library (graphql-shield for Node.js, strawberry-permissions for Python) to define authorization rules declaratively and avoid missing individual fields.
5. BOLA (Broken Object Level Authorization) via GraphQL
BOLA (equivalent to IDOR for APIs) is particularly common in GraphQL because mutations and queries accept object IDs as arguments. Without proper ownership checks, any authenticated user can modify or read any object by changing the ID.
# Vulnerable mutation
mutation {
updateOrder(id: "1337", status: "cancelled") { id status }
}
# If the API checks only authentication (not ownership), any user can cancel any order
# Correct resolver — check ownership
const resolvers = {
Mutation: {
updateOrder: async (_, { id, status }, context) => {
const order = await Order.findById(id);
if (order.userId !== context.user.id) {
throw new ForbiddenError('Not your order');
}
return order.update({ status });
}
}
};
6. Missing Rate Limiting
Unlike REST APIs where rate limiting per route is straightforward, GraphQL has a single endpoint. Naive request-count rate limiting is easily bypassed with query batching or aliasing. Implement operation-aware rate limiting:
- Rate limit by user/IP on the HTTP layer for overall request volume
- Implement query cost analysis and rate limit by total cost, not request count
- Apply specific limits to expensive mutations (like user creation or email sending)
- Consider persisted queries for trusted clients — only allow pre-registered query hashes
7. Information Disclosure via Error Messages
GraphQL errors are returned in a structured errors array in the response. By default, many GraphQL servers include detailed error messages, stack traces, or database error details that expose internal implementation details.
# Dangerous default — exposes internal details
{
"errors": [{
"message": "ERROR: duplicate key value violates unique constraint "users_email_key"",
"locations": [{"line": 1, "column": 10}],
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}]
}
# Safe — generic error with internal logging
{
"errors": [{
"message": "An internal error occurred. Please try again.",
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}]
}
# Apollo Server — mask production errors
const server = new ApolloServer({
formatError: (formattedError, error) => {
// Log internal details server-side
logger.error(error);
// Return generic message to client
if (process.env.NODE_ENV === 'production') {
return { message: 'Internal server error' };
}
return formattedError;
},
});
GraphQL Security Testing Checklist
- Send an introspection query — does it return schema details?
- Send a query with 10+ levels of nesting — does the server reject it?
- Send a batched request with 50+ operations — is rate limiting applied?
- Access another user's object by changing the ID parameter — is ownership enforced?
- Trigger an error condition — does the response include stack traces or SQL errors?
- Send a query with 100 aliases of the same field — is it rejected?
Automated scanners can identify introspection exposure and some error disclosure issues. Field-level authorization and business logic flaws require manual testing or purpose-built GraphQL security tools.