GraphQL Security Code Review Guide
Table of Contents
Introduction
GraphQL has revolutionized API development with its flexible query language and strong typing system. However, this flexibility introduces unique security challenges that differ significantly from traditional REST APIs. Understanding these GraphQL-specific vulnerabilities is essential for secure code review.
Unlike REST where each endpoint is explicitly defined, GraphQL exposes a single endpoint that can handle arbitrary queries. This means attackers have much more control over what data they request and how they request it, creating opportunities for denial of service, information disclosure, and authorization bypass attacks.
The GraphQL Security Paradox
GraphQL's greatest strength—giving clients exactly the data they need—is also its greatest security weakness. Without proper controls, attackers can craft queries that extract sensitive data, overwhelm servers, or bypass authorization entirely.
What makes GraphQL security fundamentally different from REST API security?
GraphQL Attack Surface
GraphQL exposes a rich attack surface that security reviewers must understand. From the schema layer to individual resolvers, each component presents unique security considerations.
GraphQL Attack Surface
Introspection, Query manipulation, BatchingDatabases, APIs, Authentication, AuthorizationKey Insight: GraphQL consolidates all data access into a single endpoint, making authorization particularly critical. Every resolver is a potential access control bypass point.
GraphQL Security Concerns by Layer
| Layer | Security Concern | Potential Impact |
|---|---|---|
| Schema | Introspection exposure | Full API structure disclosure |
| Query Parser | Complexity attacks | Denial of Service |
| Validation | Type confusion | Data leakage, injection |
| Resolvers | Authorization bypass | Unauthorized data access |
| Data Sources | Injection attacks | Data breach, RCE |
| Subscriptions | Resource exhaustion | DoS, information disclosure |
Basic GraphQL Schema Example
1type Query {
2 # Public query - anyone can access
3 publicPosts: [Post!]!
4
5 # Should require authentication
6 me: User
7
8 # Should require authorization - admin only!
9 users(filter: UserFilter): [User!]!
10
11 # Dangerous - allows arbitrary ID lookup
12 user(id: ID!): User
13}
14
15type Mutation {
16 # Requires auth + ownership check
17 updateProfile(input: ProfileInput!): User
18
19 # Admin only - but is it enforced?
20 deleteUser(id: ID!): Boolean
21}
22
23type User {
24 id: ID!
25 email: String! # PII - should this be exposed?
26 password: String # NEVER expose this!
27 role: Role!
28 posts: [Post!]!
29 # Circular reference - complexity concern
30 friends: [User!]!
31}In the schema above, what is the most critical security issue?
Introspection Attacks
GraphQL introspection allows clients to query the schema itself, revealing all types, fields, queries, and mutations. While useful for development, introspection in production exposes your entire API structure to attackers.
Full Introspection Query
1# This query extracts your entire schema
2query IntrospectionQuery {
3 __schema {
4 queryType { name }
5 mutationType { name }
6 subscriptionType { name }
7 types {
8 ...FullType
9 }
10 directives {
11 name
12 description
13 locations
14 args { ...InputValue }
15 }
16 }
17}
18
19fragment FullType on __Type {
20 kind
21 name
22 description
23 fields(includeDeprecated: true) {
24 name
25 description
26 args { ...InputValue }
27 type { ...TypeRef }
28 isDeprecated
29 deprecationReason
30 }
31 inputFields { ...InputValue }
32 interfaces { ...TypeRef }
33 enumValues(includeDeprecated: true) {
34 name
35 description
36 isDeprecated
37 }
38 possibleTypes { ...TypeRef }
39}
40
41fragment InputValue on __InputValue {
42 name
43 description
44 type { ...TypeRef }
45 defaultValue
46}
47
48fragment TypeRef on __Type {
49 kind
50 name
51 ofType {
52 kind
53 name
54 ofType {
55 kind
56 name
57 }
58 }
59}Introspection Reveals Everything
Introspection exposes field names like "adminDeleteUser", argument names that hint at SQL columns, deprecated fields that may have weaker security, and internal types that reveal backend architecture. This is a reconnaissance goldmine.
Targeted Introspection Queries
1# Find all queries and mutations
2{
3 __schema {
4 queryType {
5 fields { name description }
6 }
7 mutationType {
8 fields { name description }
9 }
10 }
11}
12
13# Explore a specific type
14{
15 __type(name: "User") {
16 name
17 fields {
18 name
19 type { name kind }
20 args { name type { name } }
21 }
22 }
23}
24
25# Find deprecated fields (often less secured)
26{
27 __type(name: "Query") {
28 fields(includeDeprecated: true) {
29 name
30 isDeprecated
31 deprecationReason
32 }
33 }
34}Disabling Introspection
1// Apollo Server - disable in production
2const server = new ApolloServer({
3 typeDefs,
4 resolvers,
5 introspection: process.env.NODE_ENV !== 'production',
6});
7
8// Express GraphQL
9app.use('/graphql', graphqlHTTP({
10 schema,
11 graphiql: process.env.NODE_ENV !== 'production',
12 // Custom validation to block introspection
13 validationRules: [
14 NoSchemaIntrospectionCustomRule
15 ]
16}));
17
18// Custom validation rule to block introspection
19import { GraphQLError } from 'graphql';
20
21function NoSchemaIntrospectionCustomRule(context) {
22 return {
23 Field(node) {
24 const fieldName = node.name.value;
25 if (fieldName === '__schema' || fieldName === '__type') {
26 context.reportError(
27 new GraphQLError(
28 'Introspection is disabled',
29 { nodes: [node] }
30 )
31 );
32 }
33 }
34 };
35}Why should introspection be disabled in production?
Query Complexity & DoS
GraphQL's flexibility allows clients to construct queries that consume excessive server resources. Without proper limits, a single malicious query can bring down your entire application.
Query Complexity Attack Patterns
{
user {
posts {
comments {
author {
posts {
comments {
# 100+ levels deep
}
}
}
}
}
}
}{
users(first: 10000) {
posts(first: 10000) {
comments(first: 10000) {
# Exponential growth
}
}
}
}fragment A on User {
friends {
...B
}
}
fragment B on User {
friends {
...A # Circular!
}
}Dangerous Nested Query
1# This single query could generate millions of database calls
2query ResourceExhaustion {
3 users(first: 100) { # 100 users
4 posts(first: 100) { # × 100 posts = 10,000
5 comments(first: 100) { # × 100 comments = 1,000,000
6 author { # × 1 author = 1,000,000 user lookups
7 posts(first: 100) { # × 100 posts = 100,000,000
8 title
9 body
10 }
11 }
12 }
13 }
14 }
15}
16
17# With default DataLoader, this could:
18# - Generate 100M+ database queries
19# - Consume gigabytes of memory
20# - Timeout all other requestsQuery Complexity Analysis
1import {
2 createComplexityLimitRule
3} from 'graphql-validation-complexity';
4
5// Define complexity costs per field
6const complexityConfig = {
7 scalarCost: 1,
8 objectCost: 2,
9 listFactor: 10, // Multiplier for lists
10
11 // Custom field costs
12 fieldCost: {
13 'Query.users': 10,
14 'Query.search': 20, // Expensive operation
15 'User.posts': 5,
16 'Post.comments': 5,
17 }
18};
19
20// Calculate query complexity
21function calculateComplexity(query, variables) {
22 // Example calculation for nested query
23 // users(first: 100) { posts(first: 100) { comments } }
24 // = 10 (users) × 100 × 5 (posts) × 100 × 5 (comments)
25 // = 25,000,000 complexity score!
26}
27
28// Apollo Server with complexity limit
29const server = new ApolloServer({
30 typeDefs,
31 resolvers,
32 validationRules: [
33 createComplexityLimitRule(1000, { // Max complexity: 1000
34 ...complexityConfig,
35 onCost: (cost) => {
36 console.log(`Query complexity: ${cost}`);
37 },
38 }),
39 ],
40});Query Depth Limiting
1import depthLimit from 'graphql-depth-limit';
2
3const server = new ApolloServer({
4 typeDefs,
5 resolvers,
6 validationRules: [
7 depthLimit(5), // Maximum 5 levels of nesting
8 ],
9});
10
11// Example of what gets blocked:
12// Depth 1: { users { id } } ✓
13// Depth 3: { users { posts { comments } } } ✓
14// Depth 6: { users { posts { comments { author { posts { title } } } } } } ✗
15
16// Custom depth limit with exceptions
17const customDepthLimit = depthLimit(5, {
18 ignore: [
19 'Query.simpleQuery', // Allow deeper for specific queries
20 ],
21});DoS Prevention Controls
| Control | Purpose | Recommended Value |
|---|---|---|
| Query depth limit | Prevent deeply nested queries | 5-10 levels |
| Complexity limit | Prevent resource-heavy queries | 1000-5000 points |
| Query timeout | Kill long-running queries | 10-30 seconds |
| Rate limiting | Limit requests per client | 100-1000/minute |
| Pagination limits | Cap items per page | 100 max items |
| Batch size limit | Limit batched queries | 10-20 queries |
Why is query depth limiting alone insufficient for DoS prevention?
Injection Attacks
While GraphQL's type system provides some protection, injection attacks are still possible wherever resolver code constructs queries dynamically. SQL injection, NoSQL injection, and even OS command injection can occur in GraphQL backends.
SQL Injection via GraphQL
1// VULNERABLE: String concatenation in resolver
2const resolvers = {
3 Query: {
4 // SQL Injection via search parameter
5 searchUsers: async (_, { query }) => {
6 // DANGEROUS: Unsanitized input in SQL
7 const sql = `SELECT * FROM users WHERE name LIKE '%${query}%'`;
8 return db.query(sql);
9
10 // Attacker input: "'; DROP TABLE users; --"
11 // Results in: SELECT * FROM users WHERE name LIKE '%'; DROP TABLE users; --%'
12 },
13
14 // SQL Injection via filter object
15 users: async (_, { filter }) => {
16 // DANGEROUS: Dynamic column names
17 const sql = `SELECT * FROM users ORDER BY ${filter.orderBy}`;
18 return db.query(sql);
19
20 // Attacker: { orderBy: "1; DROP TABLE users; --" }
21 },
22 }
23};Safe Parameterized Queries
1// SECURE: Parameterized queries
2const resolvers = {
3 Query: {
4 searchUsers: async (_, { query }) => {
5 // Safe: Parameterized query
6 return db.query(
7 'SELECT * FROM users WHERE name LIKE $1',
8 [`%${query}%`]
9 );
10 },
11
12 users: async (_, { filter }) => {
13 // Safe: Whitelist allowed columns
14 const allowedOrderColumns = ['name', 'created_at', 'email'];
15 const orderBy = allowedOrderColumns.includes(filter.orderBy)
16 ? filter.orderBy
17 : 'created_at';
18
19 return db.query(
20 `SELECT * FROM users ORDER BY ${orderBy}`, // Safe - whitelisted
21 []
22 );
23 },
24 }
25};NoSQL Injection in GraphQL
1// VULNERABLE: MongoDB injection
2const resolvers = {
3 Query: {
4 // Direct object injection
5 user: async (_, { filter }) => {
6 // Attacker can pass: { filter: { "$ne": null } }
7 // This returns ALL users!
8 return User.findOne(filter);
9 },
10
11 // Regex injection
12 searchUsers: async (_, { name }) => {
13 // Attacker: { name: { "$regex": ".*" } }
14 return User.find({ name: name });
15 },
16 }
17};
18
19// SECURE: Sanitize and validate input
20const resolvers = {
21 Query: {
22 user: async (_, { id }) => {
23 // Only allow specific field lookup
24 if (!isValidObjectId(id)) {
25 throw new Error('Invalid ID format');
26 }
27 return User.findById(id);
28 },
29
30 searchUsers: async (_, { name }) => {
31 // Ensure name is string, escape regex chars
32 if (typeof name !== 'string') {
33 throw new Error('Name must be a string');
34 }
35 const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36 return User.find({ name: new RegExp(escaped, 'i') });
37 },
38 }
39};Does GraphQL's type system prevent SQL injection?
Prevention Techniques
Securing GraphQL requires a defense-in-depth approach with multiple layers of protection. Here are the essential security controls every GraphQL API should implement.
GraphQL Security Checklist
| Control | Implementation | Priority |
|---|---|---|
| Disable introspection | Production environments only | Critical |
| Query depth limit | graphql-depth-limit library | Critical |
| Query complexity limit | graphql-validation-complexity | Critical |
| Rate limiting | Per-user/IP request limits | Critical |
| Field-level auth | Check permissions in every resolver | Critical |
| Input validation | Validate all arguments | High |
| Error masking | Hide internal errors from clients | High |
| Query allowlisting | Persisted queries only (APQ) | Medium |
| Timeout limits | Kill long-running queries | High |
| Audit logging | Log all queries and mutations | High |
Comprehensive Security Setup
1import { ApolloServer } from '@apollo/server';
2import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
3import depthLimit from 'graphql-depth-limit';
4import { createComplexityLimitRule } from 'graphql-validation-complexity';
5import { rateLimitDirective } from 'graphql-rate-limit-directive';
6
7// Error formatting - hide internal details
8const formatError = (error) => {
9 // Log full error internally
10 console.error('GraphQL Error:', error);
11
12 // Return sanitized error to client
13 if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
14 return new Error('An internal error occurred');
15 }
16
17 // Allow known error types through
18 return error;
19};
20
21const server = new ApolloServer({
22 typeDefs,
23 resolvers,
24
25 // Disable introspection in production
26 introspection: process.env.NODE_ENV !== 'production',
27
28 // Disable GraphQL Playground in production
29 plugins: [
30 process.env.NODE_ENV === 'production'
31 ? ApolloServerPluginLandingPageDisabled()
32 : undefined,
33 ].filter(Boolean),
34
35 // Validation rules
36 validationRules: [
37 depthLimit(5),
38 createComplexityLimitRule(1000),
39 ],
40
41 // Error masking
42 formatError,
43});
44
45// Request timeout middleware
46const timeoutMiddleware = (req, res, next) => {
47 req.setTimeout(30000, () => {
48 res.status(408).json({ error: 'Request timeout' });
49 });
50 next();
51};Persisted Queries (Query Allowlisting)
1// Only allow pre-registered queries in production
2// This prevents arbitrary query attacks entirely
3
4import { createHash } from 'crypto';
5
6// Build time: Generate query manifest
7const allowedQueries = {
8 'abc123...': 'query GetUser($id: ID!) { user(id: $id) { name } }',
9 'def456...': 'query GetPosts { posts { title } }',
10 // ... all legitimate queries from your app
11};
12
13// Runtime: Validate incoming queries
14const server = new ApolloServer({
15 typeDefs,
16 resolvers,
17 persistedQueries: {
18 cache: new InMemoryLRUCache(),
19 },
20 plugins: [{
21 async requestDidStart() {
22 return {
23 async didResolveOperation(requestContext) {
24 const { request } = requestContext;
25
26 // In production, only allow persisted queries
27 if (process.env.NODE_ENV === 'production') {
28 const queryHash = createHash('sha256')
29 .update(request.query)
30 .digest('hex');
31
32 if (!allowedQueries[queryHash]) {
33 throw new Error('Query not in allowlist');
34 }
35 }
36 },
37 };
38 },
39 }],
40});What is the benefit of persisted/allowlisted queries?