As developers, we typically love it when new API tech hits the market. One of the biggest changes in the API space in the last 5+ years has been the creation and adoption of GraphQL. GraphQL has transformed how we build and consume APIs, and gives us a choice outside of more standard REST and gRPC paradigms, offering unprecedented flexibility through its query language that lets clients request exactly the data they need. However, unsurprisingly, this power and flexibility also come with unique security challenges that differ significantly from traditional REST APIs.
A single GraphQL endpoint can expose your entire data graph, and the flexible query language that makes GraphQL powerful also creates attack surfaces that don’t exist in REST. Query depth attacks, introspection abuse, batching exploits, and field-level authorization complexities require specialized security measures that don’t generally apply to other API types.
This guide walks through the main GraphQL-specific risks that you should look out for and how to mitigate them. Whether you’re building a new GraphQL service or securing an existing one, understanding these threats is critical for protecting your API and its data.
What is a GraphQL API?
GraphQL is new enough that not every developer has necessarily worked with it, and some (who don’t keep up with the latest trends) may never even have heard of it. In short, GraphQL is a query language and runtime for APIs that enables clients to request precisely the data they need through a single endpoint. Unlike REST APIs, which expose multiple endpoints with fixed data structures, a GraphQL API provides one endpoint where clients construct queries specifying exactly which fields and nested relationships they want. It feels more like a database query than an API call.
At its core, a GraphQL service is defined by a GraphQL schema. That schema is a strongly typed contract that describes available data types, relationships, and operations. This schema defines the API surface area, specifying what data clients can query (queries, in GraphQL-speak), how they can modify data (mutations), and real-time updates they can subscribe to (subscriptions).
Here’s what makes GraphQL fundamentally different from REST:
Single endpoint architecture: Rather than multiple resource endpoints (/users, /posts, /comments), GraphQL APIs expose a single endpoint (typically /graphql) that handles all requests. This consolidation simplifies routing but concentrates security enforcement at a single point.Client-driven data fetching: Clients specify their exact data requirements in each request. A GraphQL query requesting user data with nested posts might look like:
query {
user(id: "123") {
name
email
posts {
title
comments {
text
author { name }
}
}
}
}
The GraphQL server processes this query through resolver functions, which are the business logic layer that fetches data for each requested field. The response matches the query structure exactly, returning only the specified fields.
Introspection capabilities: GraphQL schemas are introspectable, meaning clients can query the schema itself to discover available types, fields, and operations. While powerful for development, this creates API discoverability risks if not managed properly in production.
Understanding GraphQL API Security Issues
GraphQL’s query flexibility introduces security vulnerabilities distinct from REST APIs. Understanding these risks is the first step toward building secure GraphQL services. For those who are used to securing RESTful APIs, there are some overlaps, such as rate limiting and access control, but they are a bit more complex to implement when applied to GraphQL. Let’s take a look at some of the most common challenges developers face when attempting to keep these APIs secure:
Query Depth and Complexity Attacks
Unlike REST APIs with fixed response structures, GraphQL clients control query depth and complexity. Attackers can craft deeply nested queries that exhaust server resources:
query MaliciousDeepQuery {
user(id: "1") {
posts {
comments {
author {
posts {
comments {
author {
posts {
# ... continues through relationship cycles
}
}
}
}
}
}
}
}
}
Rather than recursion at the query language level (which GraphQL doesn’t support), depth attacks exploit arbitrarily nested relationships in your schema. When relationships form cycles (users -> posts -> authors -> posts), a single query can multiply the number of database calls across nested relationships, overwhelming your GraphQL service. This amplifies the N+1 query problem (that also exists in RESTful APIs) since each level of nesting can spawn more resolver executions and database queries.
While REST endpoints occasionally include nested expansions via ?include= or expand= query parameters, the maximum depth is controlled by the API provider. GraphQL shifts that control to the client, removing this implicit boundary unless you explicitly enforce depth or cost limits (a concept we will discuss in the best practices below).
Introspection and API Discoverability
GraphQL’s introspection feature lets clients query the schema to discover available types, fields, and operations. During development, introspection queries help developers explore the API:
query IntrospectionQuery {
__schema {
types {
name
fields {
name
type { name }
}
}
}
}
Leaving introspection unrestricted in production exposes your entire API structure to potential attackers. They can map all available fields, identify sensitive data types, and discover internal-only operations. The returned schema can also include deprecated fields or admin-only mutations that shouldn’t be publicly visible.
The real security issue isn’t introspection itself but introspection combined with missing authorization. If your field-level authorization is solid, schema visibility poses less risk. However, exposed schema structure still helps attackers understand your data model and craft targeted attacks.
REST APIs don’t face this issue since there’s no built-in mechanism for clients to discover all available endpoints and their parameters. Attackers must guess endpoint structures or rely on leaked documentation.
Batching and Query Cost Attacks
Many GraphQL server implementations support batching, which allows clients to send multiple queries or mutations in a single HTTP request. This feature improves performance for legitimate clients but enables attackers to amplify denial of service attacks, with queries like this:
[
{ "query": "query { expensiveOperation1 }" },
{ "query": "query { expensiveOperation2 }" },
{ "query": "query { expensiveOperation3 }" },
// ... repeated hundreds of times
]
A single network call can trigger hundreds of expensive operations, making it difficult to detect abuse through traditional rate limiting that counts HTTP requests rather than actual GraphQL operations. Even with query depth limits, an attacker can batch multiple maximum-complexity queries to overwhelm your GraphQL endpoint.
Now, it’s important to note that batching support varies by implementation. This vulnerability will differ in terms of impact based on whether you’re using Apollo’s apollo-link-batch-http, Relay’s batch middleware, or some custom gateways provide this functionality. Batching itself is not part of the core GraphQL specification so implementations differ.
REST APIs typically handle one operation per request, making batching attacks less practical (although sometimes batch endpoints exist that take a larger JSON payload with multiple entries). Rate limiting based on HTTP request count works reasonably well for REST but fails to adequately protect GraphQL services.
Field-Level Authorization Challenges
GraphQL’s flexible field selection complicates authorization. Unlike REST endpoints, where you control the entire response, GraphQL resolvers must enforce authorization for every field individually. For example, imagine if you had a request like the one below with multiple fields and different levels of access:
query {
user(id: "123") {
name # Public field
email # Private field
socialSecurity # Highly sensitive field
}
}
Implementing proper authorization checks at the field level requires careful design. It’s not enough to authorize access to the overall user object; you must verify that the requesting user has permission to see email and socialSecurity fields specifically. Missing field-level checks create data exposure risks where authenticated users access fields they shouldn’t see.
REST APIs handle this more simply: if a user can access /api/users/123, they receive the entire predefined response. You control what fields are included at the endpoint level, not field-by-field as in GraphQL.
Resolver-Level Input Validation Gaps
GraphQL resolvers are the business logic layer where field values are fetched. Each resolver receives argument values that must be validated. In the example below, we can see that the query includes a limit argument with a numeric value:
query {
searchUsers(limit: 999999999) {
name
}
}
Without proper validation, malicious argument values can trigger performance issues (like the one above that is returning over a million users), injection attacks, or bypass security controls. Unlike REST, where input validation happens at the route handler level for all parameters, GraphQL requires validation in each resolver that accepts arguments.
GraphQL Security Best Practices
Now that we know the challenges, let’s look at some of the best practices to mitigate them. Securing GraphQL APIs requires implementing protections that address GraphQL-specific attack vectors while maintaining the flexibility that makes GraphQL valuable. Here are essential security measures every GraphQL service should implement.
1. Control Introspection Access
Introspection should be restricted in environments where schema visibility itself poses a risk. If your production GraphQL service includes internal admin fields, deprecated operations, or exposes more of your data model than intended for public consumption, consider disabling introspection for unauthenticated users.
Most GraphQL server implementations provide configuration options to control introspection. For Apollo Server:
// Simple example: disable introspection in production by default.
// In many systems you'll replace this with role-based access instead.
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
For environments requiring introspection in production (internal tools, authenticated developer access), implement attribute-based access control that restricts introspection queries to specific user roles:
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart({ request, context }) {
if (request.operationName === 'IntrospectionQuery') {
if (!context.user?.isAdmin) {
throw new Error('Introspection is disabled for unauthorized users');
}
}
},
},
],
});
Many production GraphQL APIs keep introspection enabled because developer tooling depends on it, and schema visibility alone doesn’t expose data. The critical requirement is solid field-level authorization since disabling introspection must never substitute for proper access controls. Restricting introspection is a defense in depth, not a complete security solution.
2. Implement Query Depth and Complexity Limiting
Protecting against malicious queries requires limiting both depth (levels of nesting) and complexity (estimated computational cost). Implement these limits at the GraphQL service level before queries reach your resolver functions.
Depth limiting restricts how many levels deep a query can nest. In Apollo Server, this can be implemented like this:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Maximum 5 levels deep
});
You can see that the configuration includes a depthLimit, which means that queries with a depth limit greater than five will be rejected, preventing deeply nested relationship traversal attacks that trigger excessive resolver execution.Query cost analysis assigns cost values to fields based on their computational expense and rejects queries exceeding a maximum cost threshold. Here is another example of what that would look like in an Apollo Server config:
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query cost:', cost),
formatErrorMessage: (cost) =>
`Query exceeds complexity limit of 1000 (calculated: ${cost})`
})
]
});
You can see that under validationRules, a cost complexity rule has been added (createComplexityLimitRule) that limits the query cost to 1000. You also need to configure cost analysis to match your resolver logic, making it so that fields returning lists of multiple object instances cost more than simple scalar fields. This prevents attackers from crafting queries that stay under depth limits but still consume excessive server resources through expensive operations.
3. Apply Field-Level Authorization
Since each field in a GraphQL query is somewhat of an independent unit, GraphQL resolvers must enforce authorization at the field level, not just at the query level. Every resolver function should verify that the current user has permission to access that specific field’s data.
Implement authorization logic consistently across all resolvers. Here is a simple example of what those checks may look like if you were to implement them directly in code (versus using an API gateway or something that abstracts this logic):
const resolvers = {
User: {
email: (parent, args, context) => {
// Allow users to see their own email
if (context.user.id === parent.id) {
return parent.email;
}
// Admins can see any email
if (context.user.role === 'admin') {
return parent.email;
}
// Log unauthorized access attempts for monitoring
logger.warn('Unauthorized email access attempt', {
userId: context.user.id,
targetUserId: parent.id
});
// Return null to prevent existence disclosure
return null;
},
socialSecurityNumber: (parent, args, context) => {
// Highly sensitive - only the user themselves
if (context.user.id !== parent.id) {
logger.warn('Unauthorized SSN access attempt', {
userId: context.user.id,
targetUserId: parent.id
});
throw new Error('Unauthorized access to sensitive data');
}
return parent.socialSecurityNumber;
}
}
};
Returning null for unauthorized field access prevents existence disclosure, though security teams should still log authorization failures for monitoring and audit purposes. This approach ensures proper authorization checks happen regardless of how clients construct their queries.
For complex authorization rules, implement an attribute-based access control layer that centralizes permission logic. Here is a short example of what this logic might look like:
const checkFieldAccess = (user, resource, field) => {
const rules = {
'User.email': (user, resource) =>
user.id === resource.id || user.role === 'admin',
'User.socialSecurityNumber': (user, resource) =>
user.id === resource.id,
};
const rule = rules[`${resource.__typename}.${field}`];
return rule ? rule(user, resource) : true;
};
Never assume that authenticating at the GraphQL endpoint level provides sufficient authorization for all fields. Authentication verifies identity; authorization at the field level ensures users only access objects that they have permission to see. Use similar strategies to what you’d implement at the database level to protect fields, going as granular as needed to keep data secure.
4. Use Persisted Queries for External Clients
Persisted queries (also called query allowlisting) restrict clients to a predefined set of approved queries identified by hash or ID rather than sending arbitrary query strings. This significantly reduces the attack surface by preventing arbitrary query construction, though it must still be combined with authorization and cost controls.
Clients send a query identifier instead of the full query text. This means instead of sending this:
POST /graphql
{ "query": "query GetUser($id: ID!) { user(id: $id) { name email } }" }
The client will instead just send a hash of the query:
POST /graphql
{ "extensions": { "persistedQuery": { "sha256Hash": "abc123..." } },
"variables": { "id": "123" } }
The GraphQL server maintains a registry of approved query hashes and rejects any request with an unknown hash or a custom query string. This approach prevents attackers from crafting custom malicious queries, though they can still:
- Modify variables within allowed queries
- Abuse high-cost queries that exist on the allowlist
- Exploit unauthorized fields in approved queries
To apply this strategy while keeping things flexible, you might want to implement persisted queries for production client applications while maintaining normal query support for internal tools. A simple implementation of this might look something like this:
const persistedQueries = new Map([
['abc123...', 'query GetUser($id: ID!) { user(id: $id) { name email } }'],
['def456...', 'mutation CreatePost($title: String!) { createPost(title: $title) { id } }']
]);
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart({ request, context }) {
// Allow full queries from internal tools
if (context.source === 'internal') return;
// External clients must use persisted queries
const hash = request.extensions?.persistedQuery?.sha256Hash;
if (request.query && !hash) {
throw new Error('Direct queries not allowed. Use persisted queries.');
}
if (hash) {
request.query = persistedQueries.get(hash);
if (!request.query) {
throw new Error('Unknown query hash');
}
}
}
}
]
});
We can see that in the requestDidStart that internal clients bypass the logic (via the return statement) and external requests are forced into the persisted query logic. While persisted queries do limit flexibility, they dramatically reduce the attack surface for public-facing GraphQL clients where you control both the client and server implementation.
5. Implement GraphQL-Aware Rate Limiting
As we’ve already covered, traditional rate limiting based on HTTP request count doesn’t adequately protect GraphQL services. Since clients can batch multiple queries into a single HTTP request and craft queries with varying computational costs, you need GraphQL-aware rate limiting that accounts for actual operation count and complexity.
The most consistent way to do this is to use an API gateway that supports per-request limits (controlling individual query costs), per-user throttling (preventing sustained abuse), and cost-based budgets (tracking cumulative resource consumption). Most API gateways that support GraphQL (which gives you a pretty wide array of support from major vendors) will give you lots of options in terms of implementing these various layers of rate limiting. That said, you can also implement this logic in the application layer.
6. Validate and Sanitize All User Input
GraphQL resolver functions receive argument values from incoming requests that must be validated before use. Every resolver accepting arguments should validate data types, formats, and ranges before passing data to your business logic layer.
You can implement validation at the resolver level easily, following a pattern like this:
const resolvers = {
Query: {
searchUsers: async (parent, args, context) => {
// Validate limit argument
const limit = parseInt(args.limit);
if (isNaN(limit) || limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100');
}
// Validate search query
if (typeof args.query !== 'string' || args.query.length > 100) {
throw new Error('Invalid search query');
}
// Sanitize input to prevent injection attacks
const sanitizedQuery = args.query.replace(/[^\w\s]/gi, '');
return context.dataSources.users.search(sanitizedQuery, limit);
}
}
};
For complex input types, define validation schemas that reject malformed data using libraries like Joi. For example, Joi can be integrated with GraphQL to provide more fine-grained input validation beyond GraphQL’s built-in type system, like so:
import Joi from 'joi';
const userInputSchema = Joi.object({
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(150),
role: Joi.string().valid('user', 'admin', 'moderator')
});
// In resolver
const { error, value } = userInputSchema.validate(args.input);
if (error) {
throw new Error(`Validation failed: ${error.message}`);
}
Just like any other type of API, never trust client input sent to GraphQL operations. You need to make sure to validate all argument values, sanitize strings before using them in database queries, and enforce business rules at the resolver level. GraphQL’s type system provides basic type checking but doesn’t validate ranges, formats, or business logic constraints (the stuff that really matters when it comes to security).
How StackHawk Helps Secure GraphQL APIs
StackHawk provides automated dynamic application security testing (DAST) built specifically for GraphQL APIs. The platform uses introspection to discover your complete GraphQL schema, automatically generating test queries that include fields, dependencies, expected input values, and variable arguments. HawkScan then executes security tests against your running application, simulating real-world attacks to identify vulnerabilities like SQL injection, broken object-level authorization (BOLA), field-level authorization issues, and other OWASP API Security Top 10 risks before they reach production.
The platform integrates directly into CI/CD pipelines, checking for new vulnerabilities on every pull request and enabling developers to find and fix security issues during development when they’re easiest to address. StackHawk’s GraphQL support allows you to provide realistic variable values for GraphQL operations, testing business logic vulnerabilities that require specific input to trigger. The scanner supports authenticated scanning, federated GraphQL architectures, and provides developer-friendly remediation guidance with request/response data and cURL recreation for each finding.
Unlike traditional security tools that require security expertise, StackHawk is built for developers with a simple configuration process. Just add your GraphQL schema path to the stackhawk.yml file, and you’re ready to start scanning. The platform provides centralized visibility across your entire API portfolio (REST, GraphQL, gRPC, and SOAP), helping security teams monitor what’s tested, what’s at risk, and what vulnerabilities exist across all your GraphQL services. Learn more about GraphQL security testing with StackHawk or start testing your GraphQL APIs today.
Conclusion
GraphQL’s flexibility creates security risks you don’t see in traditional REST APIs. Depth limits, cost analysis, field-level authorization, controlled introspection, and GraphQL-aware rate limiting are needed to keep these services secure. As per other API technologies, no single technique provides complete security; while there are API security best practices that apply to all API types, you must add them into layers of defense that evolve with your API and the latest security risks.
As part of operating these APIs, regularly audit your schema for new fields needing authorization checks, monitor for unusual query patterns, and stay informed about emerging GraphQL attack techniques. Combine secure schema design with continuous testing to catch vulnerabilities before they reach production.Ready to secure your GraphQL APIs? Schedule a demo today to see StackHawk in flight and begin automated GraphQL security testing in your CI/CD pipeline today.

