GraphQL has become the API layer of choice for many modern applications, and its flexible query model is also a different attack surface from what most developers and security teams are used to assessing for REST APIs. The vulnerabilities that matter in GraphQL are not the same as those in REST — introspection is an attack enabler, resolvers are injection sinks, batching is a rate-limit bypass, and missing field-level authorization checks can expose data that HTTP endpoint permission checks were supposed to protect.
This guide covers the four core GraphQL security concerns with code examples for each.
1. Introspection in Production
GraphQL’s introspection capability allows any client to query the schema: every type, every field, every argument, and every relationship. This is useful in development. In production, it is a reconnaissance gift to attackers.
With introspection enabled, a single query exposes your entire data model:
query IntrospectSchema {
__schema {
types {
name
fields {
name
type { name }
args { name type { name } }
}
}
}
}
An attacker who can run this query knows your data structures, can identify sensitive fields (anything named password, token, secret, key, ssn), and understands the mutation surface for privilege escalation.
Disable introspection in production:
In Python with Strawberry:
import strawberry
from strawberry.extensions import DisableValidation
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[
# Disable introspection in non-development environments
]
)
# Using graphene-django: override the view
from graphene_django.views import GraphQLView
class SecureGraphQLView(GraphQLView):
def execute_graphql_request(self, request, data, query, *args, **kwargs):
if "__schema" in query or "__type" in query:
return None # Return 400 or empty response
return super().execute_graphql_request(request, data, query, *args, **kwargs)
In Node.js with Apollo Server:
const { ApolloServer } = require('@apollo/server');
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
plugins: [
{
requestDidStart({ request }) {
if (process.env.NODE_ENV === 'production') {
const query = request.query ?? '';
if (query.includes('__schema') || query.includes('__type')) {
throw new Error('Introspection is disabled in production');
}
}
}
}
]
});
Note that disabling introspection does not hide your schema from a determined attacker — field suggestion errors and tooling like Clairvoyance can partially reconstruct schemas. Introspection control is one layer, not a complete fix.
2. Injection Through Resolvers
GraphQL resolvers are the functions that translate a GraphQL query into database or service calls. When resolvers construct queries by concatenating GraphQL argument values into raw query strings, the result is injection — SQL, NoSQL, or command injection depending on the backend.
Vulnerable pattern (Python + SQLAlchemy raw SQL):
# VULNERABLE: string interpolation of GraphQL argument into SQL
@strawberry.field
def user(self, username: str) -> Optional[User]:
query = f"SELECT * FROM users WHERE username = '{username}'"
result = db.execute(query)
return result.fetchone()
An attacker passes ' OR '1'='1 as the username argument to extract all users, or '; DROP TABLE users; -- to execute destructive commands.
Secure pattern — parameterised queries:
# SECURE: parameterised query with bound argument
@strawberry.field
def user(self, username: str) -> Optional[User]:
result = db.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)
return result.fetchone()
Secure pattern in JavaScript (with pg):
// VULNERABLE
async function getUser(username) {
const result = await pool.query(
`SELECT * FROM users WHERE username = '${username}'`
);
return result.rows[0];
}
// SECURE — parameterised
async function getUser(username) {
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username]
);
return result.rows[0];
}
The same principle applies to NoSQL injection in MongoDB resolvers — use parameterised queries or ODM methods that handle escaping, never interpolate GraphQL arguments directly into query documents.
3. Batching and DoS Attacks
GraphQL supports query batching — sending multiple operations in a single HTTP request as a JSON array. Batching is a legitimate feature for reducing network overhead in clients, but it allows attackers to bypass rate limiting that counts HTTP requests rather than operations. A single HTTP request containing 500 mutations can bypass a rate limiter configured to allow 100 requests per minute.
Without batching limit — an attacker can brute-force an OTP in one request:
[
{"query": "mutation { login(email: \"[email protected]\", otp: \"000000\") { token } }"},
{"query": "mutation { login(email: \"[email protected]\", otp: \"000001\") { token } }"},
...
{"query": "mutation { login(email: \"[email protected]\", otp: \"999999\") { token } }"}
]
Implement batch limit in Apollo Server:
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart({ request }) {
// Reject batch requests exceeding the limit
if (Array.isArray(request.body)) {
if (request.body.length > 5) {
throw new Error('Batch limit exceeded. Maximum 5 operations per request.');
}
}
}
}
]
});
Query depth limiting — prevent nested query amplification:
const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // Reject queries deeper than 7 levels
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10 // Lists multiply cost — penalise expensive collection queries
})
]
});
# Python equivalent with graphene and graphql-core
from graphql import validate, parse, build_schema
from graphql.validation.rules import NoDeprecatedCustomRule
def validate_query_depth(query_string: str, max_depth: int = 7) -> bool:
def get_depth(node, depth=0):
if hasattr(node, 'selection_set') and node.selection_set:
return max(get_depth(sel, depth + 1) for sel in node.selection_set.selections)
return depth
document = parse(query_string)
for definition in document.definitions:
if get_depth(definition) > max_depth:
return False
return True
4. Field-Level Authorization
GraphQL schema design separates type definitions from access control. A field exists on a type because it is structurally relevant to that type — not because every caller should be able to read it. Missing field-level authorization checks mean that a user who can query any field on a type can query all fields on that type, even if HTTP endpoint checks would have blocked them elsewhere.
Vulnerable resolver — no field-level check:
@strawberry.type
class User:
id: str
email: str
# Missing authorization — any authenticated user can read any other user's SSN
ssn: str
salary: float
Secure pattern — field-level permission check:
import strawberry
from strawberry.types import Info
from functools import wraps
def require_permission(permission: str):
def decorator(func):
@wraps(func)
async def wrapper(root, info: Info, **kwargs):
user = info.context.user
if not user or permission not in user.permissions:
raise PermissionError(f"Permission denied: {permission} required")
return await func(root, info, **kwargs)
return wrapper
return decorator
@strawberry.type
class User:
id: str
email: str
@strawberry.field
@require_permission("hr:read_sensitive")
def ssn(self, info: Info) -> str:
return self._ssn
@strawberry.field
@require_permission("hr:read_sensitive")
def salary(self, info: Info) -> float:
return self._salary
Node.js equivalent with graphql-shield:
const { shield, rule, and } = require('graphql-shield');
const isAuthenticated = rule({ cache: 'contextual' })(
async (parent, args, ctx) => ctx.user !== null
);
const hasHRPermission = rule({ cache: 'contextual' })(
async (parent, args, ctx) => ctx.user?.permissions?.includes('hr:read_sensitive')
);
const permissions = shield({
User: {
ssn: and(isAuthenticated, hasHRPermission),
salary: and(isAuthenticated, hasHRPermission),
email: isAuthenticated,
id: isAuthenticated
}
});
Security Testing Your GraphQL API
A quick checklist before deploying a GraphQL endpoint:
- Introspection test — confirm
__schemaqueries return an error in production - Batching test — send a batch of 50 identical queries and verify rejection
- Depth test — send a query nested 15 levels deep and confirm rejection
- Injection test — pass
' OR '1'='1as string arguments and confirm no SQL errors in response - Authorization test — authenticate as a low-privilege user and attempt to read high-privilege fields
Tools: graphql-cop automates most of these checks.