API Security

GraphQL Security: Introspection, Query Complexity, and Injection

GraphQL introduces unique security challenges that REST APIs don't have. Learn how to disable introspection in production, limit query depth and complexity, prevent batching attacks, and enforce authorization at the field level.

September 15, 20258 min readShipSafer Team

GraphQL's flexibility — the ability for clients to query exactly the fields they need — is also its primary security challenge. A single GraphQL endpoint can expose every model in your system, and without careful guardrails, a malicious client can craft queries that exhaust your server, extract unauthorized data, or enumerate your entire schema.

This guide covers the GraphQL security controls every production API needs.

1. Disable Introspection in Production

GraphQL's introspection feature lets clients query the schema itself — what types exist, what fields they have, what arguments they accept. During development this is invaluable; in production it gives attackers a complete map of your API.

# A single introspection query
curl -X POST https://api.example.com/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ __schema { types { name fields { name } } } }"}'

If introspection is enabled, this returns every type and field in your system. Attackers use this to identify mutation endpoints, admin-only fields, and potential injection points without ever reading your documentation.

Disabling with Apollo Server

import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production', // disable in prod
});

Disabling with graphql-yoga or raw graphql-js

import { createYoga } from 'graphql-yoga';
import { NoSchemaIntrospectionCustomRule } from 'graphql';

const yoga = createYoga({
  schema,
  validationRules: process.env.NODE_ENV === 'production'
    ? [NoSchemaIntrospectionCustomRule]
    : [],
});

Note: disabling introspection is not a substitute for authorization — it merely reduces information disclosure. Determined attackers can still fuzz your API.

2. Query Depth Limiting

GraphQL allows nested queries that can reach deeply into your object graph. Without depth limits, an attacker can craft a trivially small query that causes your server to resolve thousands of database calls:

# Deeply nested — each level triggers new DB queries
{
  user {
    friends {
      friends {
        friends {
          friends {
            friends {
              name
            }
          }
        }
      }
    }
  }
}

Implementing Depth Limiting

npm install graphql-depth-limit
import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5), // Reject queries nested more than 5 levels deep
  ],
});

A depth limit of 5–7 is reasonable for most APIs. Review your deepest legitimate queries before setting the limit.

3. Query Complexity Analysis

Depth alone doesn't capture complexity. A query with many sibling fields at the same depth can still be expensive. Complexity analysis assigns a cost to each field and rejects queries that exceed a threshold.

npm install graphql-query-complexity
import { ApolloServer } from '@apollo/server';
import {
  createComplexityLimitRule,
  fieldExtensionsEstimator,
  simpleEstimator,
} from 'graphql-query-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    createComplexityLimitRule(1000, {
      estimators: [
        // Default cost: 1 per field
        simpleEstimator({ defaultComplexity: 1 }),
        // Field-level overrides via schema extensions
        fieldExtensionsEstimator(),
      ],
      onCost: (cost) => console.log(`Query complexity: ${cost}`),
    }),
  ],
});

You can override costs per field in your type definitions:

const typeDefs = gql`
  type Query {
    users: [User!]! @complexity(value: 10, multipliers: ["limit"])
    user(id: ID!): User @complexity(value: 1)
  }
`;

4. Batching Attack Prevention

GraphQL allows query batching — sending an array of operations in a single HTTP request. This can be used to perform large numbers of operations while appearing to a rate limiter as a single request:

[
  { "query": "mutation { login(email: \"a@a.com\", password: \"password1\") { token } }" },
  { "query": "mutation { login(email: \"a@a.com\", password: \"password2\") { token } }" },
  { "query": "mutation { login(email: \"a@a.com\", password: \"password3\") { token } }" }
]

If your rate limiter counts HTTP requests rather than individual operations, this bypasses it entirely.

Mitigations

// Option 1: Disable batching entirely
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';

// In your Express setup, check for array body:
app.use('/graphql', (req, res, next) => {
  if (Array.isArray(req.body)) {
    return res.status(400).json({ error: 'Batched requests are not allowed' });
  }
  next();
});

// Option 2: Limit batch size
app.use('/graphql', (req, res, next) => {
  if (Array.isArray(req.body) && req.body.length > 5) {
    return res.status(400).json({ error: 'Batch size limit exceeded' });
  }
  next();
});

Rate limit by operation count, not just request count.

5. Authorization at Field Level

A common mistake is to authorize at the resolver's entry point (e.g., the users query) but not on individual fields within the returned objects. This leads to BOLA (Broken Object Level Authorization) vulnerabilities where a user can read another user's private fields.

// WRONG — only checks if user is authenticated, not if they can see each field
const resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      if (!context.user) throw new Error('Unauthorized');
      return User.findById(id); // Returns ALL fields including sensitive ones
    },
  },
};

Field-Level Authorization with GraphQL Shield

npm install graphql-shield
import { shield, rule, and } from 'graphql-shield';

const isAuthenticated = rule()((parent, args, ctx) => ctx.user !== null);

const isOwner = rule()((parent, args, ctx) => {
  return parent.userId === ctx.user.userId;
});

const isAdmin = rule()((parent, args, ctx) => ctx.user.role === 'admin');

export const permissions = shield({
  Query: {
    users: isAdmin,
    me: isAuthenticated,
  },
  User: {
    email: and(isAuthenticated, isOwner),
    creditCardLast4: and(isAuthenticated, isOwner),
    passwordHash: isAdmin, // Should never be exposed, but just in case
  },
  Mutation: {
    deleteUser: isAdmin,
    updateProfile: and(isAuthenticated, isOwner),
  },
});

Alternatively, enforce authorization inside each resolver or use a data masking approach that strips fields before returning:

const resolvers = {
  User: {
    email: (parent, args, context) => {
      if (context.user.userId !== parent.userId && context.user.role !== 'admin') {
        return null; // or throw new ForbiddenError()
      }
      return parent.email;
    },
  },
};

6. Input Validation in GraphQL

GraphQL's type system provides basic validation (string vs int, required fields), but it does not validate the semantic meaning of inputs. Always add application-level validation:

import { z } from 'zod';

const CreateUserInput = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128),
  role: z.enum(['user', 'moderator']), // Prevent privilege escalation
});

const resolvers = {
  Mutation: {
    createUser: async (_, args, context) => {
      const result = CreateUserInput.safeParse(args.input);
      if (!result.success) {
        throw new UserInputError('Invalid input', {
          validationErrors: result.error.flatten(),
        });
      }

      const { email, password, role } = result.data;
      // ... create user
    },
  },
};

7. GraphQL-Specific Injection Attacks

While GraphQL's type system prevents traditional SQL injection at the query parsing level, injection can still occur if you use user-provided values in database queries within resolvers.

// VULNERABLE — passing GraphQL args directly to MongoDB regex
const resolvers = {
  Query: {
    searchUsers: async (_, { term }) => {
      // If term is { "$regex": ".*", "$options": "i" }, returns all users
      return User.find({ name: term });
    },
  },
};

// SAFE — validate and use a string pattern
const resolvers = {
  Query: {
    searchUsers: async (_, { term }) => {
      const safe = z.string().max(100).parse(term);
      return User.find({ name: { $regex: safe, $options: 'i' } });
    },
  },
};

Also guard against NoSQL injection by validating that input values are the expected primitive types before passing them to database queries.

8. Persisted Queries

Persisted queries are an advanced technique where only pre-registered query hashes are accepted. This prevents attackers from sending arbitrary queries at all:

import { ApolloServer } from '@apollo/server';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';

// Server — only allow registered hashes
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    {
      requestDidStart() {
        return {
          async didResolveOperation({ request }) {
            if (!request.extensions?.persistedQuery) {
              throw new ForbiddenError('Only persisted queries are allowed');
            }
          },
        };
      },
    },
  ],
});

This approach is most effective for public-facing APIs with known clients (your own web/mobile apps).

Summary Checklist

ControlTool
Disable introspection in productionintrospection: false in ApolloServer
Query depth limitinggraphql-depth-limit
Query complexity limitinggraphql-query-complexity
Batch attack preventionMiddleware to reject or limit array bodies
Field-level authorizationgraphql-shield or per-field resolver checks
Input validationZod / Joi inside resolvers
NoSQL injection preventionValidate input types before DB queries
Persisted queriesApollo persisted queries for known clients

GraphQL security requires layered controls. The schema's expressiveness is a feature for legitimate clients and an attack surface for malicious ones — every production deployment needs at minimum depth limits, complexity analysis, and proper field-level authorization.

graphql
api security
query complexity
introspection
authorization

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.