Skip to main content
Back to blog
April 28, 2026|8 min read|Antoine Duno

GraphQL API Security Guide 2026: Introspection, Depth Limiting & Auth

GraphQL security introduces unique challenges that REST APIs do not face — flexible queries mean attackers can probe your schema, exhaust resources, and bypass authorization with a single request.

ZeriFlow Team

1,314 words

GraphQL API Security Guide 2026: Introspection, Depth Limiting & Auth

GraphQL security is a distinct discipline from REST API security. GraphQL's flexibility — clients choose exactly what data they receive — creates unique attack vectors: schema enumeration via introspection, resource exhaustion via deeply nested queries, and authorization bypasses via field aliasing. This guide covers every critical mitigation.

Before diving in, scan your live GraphQL endpoint with ZeriFlow — it checks for exposed introspection, missing security headers, and TLS issues in one free scan.


1. Disable Introspection in Production

GraphQL introspection allows clients to query the entire schema — field names, types, relationships, and mutations. In development, this powers tools like GraphiQL. In production, it is a roadmap for attackers.

Disable with graphql-js:

javascript
const { NoSchemaIntrospectionCustomRule } = require('graphql');
const { ApolloServer } = require('@apollo/server');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  validationRules: process.env.NODE_ENV === 'production'
    ? [NoSchemaIntrospectionCustomRule]
    : [],
});

Disable in Apollo Server 4:

typescript
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: false, // Disable for production builds
});

For graphql-yoga:

typescript
import { createYoga, useDisableIntrospection } from 'graphql-yoga';

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

Note: Disabling introspection does not stop determined attackers who will use field-guessing tools. It raises the cost significantly but should be combined with other mitigations.


2. Query Depth Limiting

A single GraphQL query can traverse deeply nested relationships and cause exponential database load:

graphql
# This could hit your DB millions of times
query {
  users {
    friends {
      friends {
        friends {
          friends { email }
        }
      }
    }
  }
}

Limit query depth with graphql-depth-limit:

bash
npm install graphql-depth-limit
javascript
const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(5)], // Max 5 levels of nesting
});

Also limit query complexity with graphql-query-complexity:

bash
npm install graphql-query-complexity
javascript
const { createComplexityLimitRule } = require('graphql-query-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5),
    createComplexityLimitRule(1000, {
      onCost: (cost) => console.log('Query cost:', cost),
      formatErrorMessage: (cost) =>
        `Query complexity ${cost} exceeded limit of 1000`,
    }),
  ],
});

3. Rate Limiting GraphQL Endpoints

GraphQL typically exposes a single POST endpoint, making standard path-based rate limiting insufficient. Rate limit on operation name or complexity.

Using graphql-rate-limit:

bash
npm install graphql-rate-limit
javascript
const { createRateLimitRule } = require('graphql-rate-limit');

const rateLimitRule = createRateLimitRule({
  identifyContext: (ctx) => ctx.user?.id || ctx.req.ip,
  formatError: () => 'Too many requests, please slow down.',
});

const resolvers = {
  Query: {
    // 10 requests per minute for this field
    sensitiveData: rateLimitRule({ max: 10, window: '1m' })(
      async (_parent, args, ctx) => {
        return getSensitiveData(args.id);
      }
    ),
  },
};

Combine with express-rate-limit for global HTTP-level limiting:

javascript
const rateLimit = require('express-rate-limit');

app.use(
  '/graphql',
  rateLimit({
    windowMs: 15 * 60 * 1000,
    max:      100,
    message: { errors: [{ message: 'Rate limit exceeded' }] },
  })
);

4. Field-Level Authorization

Never rely solely on query-level auth checks. Each resolver must independently verify authorization.

javascript
const resolvers = {
  Query: {
    user: async (_parent, { id }, ctx) => {
      // 1. Check authentication
      if (!ctx.user) throw new GraphQLError('Unauthenticated', {
        extensions: { code: 'UNAUTHENTICATED' }
      });

      const user = await User.findById(id);

      // 2. Check authorization — can this user see this record?
      if (!ctx.user.isAdmin && ctx.user.id !== user.id) {
        throw new GraphQLError('Unauthorized', {
          extensions: { code: 'FORBIDDEN' }
        });
      }

      return user;
    },
  },
  User: {
    // Even if you can see a User, you need admin to see their email
    email: (parent, _args, ctx) => {
      if (!ctx.user?.isAdmin && ctx.user?.id !== parent.id) {
        return null; // Return null instead of throwing for graceful degradation
      }
      return parent.email;
    },
    // Admin-only field
    paymentInfo: (parent, _args, ctx) => {
      if (!ctx.user?.isAdmin) return null;
      return parent.paymentInfo;
    },
  },
};

Use graphql-shield for declarative, reusable authorization rules:

javascript
const { rule, shield, and } = require('graphql-shield');

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

const permissions = shield({
  Query: {
    users:       and(isAuthenticated, isAdmin),
    currentUser: isAuthenticated,
  },
  User: {
    email:       isAuthenticated,
    paymentInfo: isAdmin,
  },
});

5. Defending Against Batching Attacks

GraphQL supports batching — sending multiple operations in one request. Attackers abuse this for brute-force attacks that bypass per-request rate limits.

json
// One HTTP request, 1000 login attempts
[
  {"query": "mutation { login(email: 'user1@x.com', password: 'abc') }"},
  {"query": "mutation { login(email: 'user2@x.com', password: 'abc') }"},
  ...
]

Disable or limit batching:

javascript
// Apollo Server 4
const server = new ApolloServer({
  typeDefs,
  resolvers,
  allowBatchedHttpRequests: false, // Disable batching entirely
});

If you need batching for legitimate clients, limit batch size:

javascript
app.use('/graphql', (req, res, next) => {
  if (Array.isArray(req.body) && req.body.length > 10) {
    return res.status(400).json({
      errors: [{ message: 'Batch size limited to 10 operations' }],
    });
  }
  next();
});

Also scan your GraphQL endpoint with ZeriFlow to check for common misconfiguration patterns from the outside.


6. Persisted Queries and Schema Allowlisting

For production APIs, consider only allowing pre-approved queries using Apollo's Automatic Persisted Queries (APQ) or a query allowlist.

javascript
// Only accept queries that match a known hash
const { ApolloServer } = require('@apollo/server');
const { PersistedQueryNotFoundError } = require('@apollo/server/errors');

// In production, reject any query not in your allowlist
const allowlistedQueries = new Map([
  ['abc123', 'query GetUser($id: ID!) { user(id: $id) { name email } }'],
  // ... all legitimate queries
]);

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [{
    requestDidStart: () => ({
      async parsingDidStart({ request }) {
        if (process.env.NODE_ENV === 'production') {
          const hash = request.extensions?.persistedQuery?.sha256Hash;
          if (!hash || !allowlistedQueries.has(hash)) {
            throw new PersistedQueryNotFoundError();
          }
        }
      },
    }),
  }],
});

FAQ

### Q: Should I always disable GraphQL introspection in production? A: Yes for public-facing APIs. The only exception is if you are building a developer platform where your API is the product and schema exploration is a feature. Even then, require authentication before allowing introspection.

### Q: How do I prevent N+1 query attacks in GraphQL? A: Use DataLoader to batch and cache database calls. Without DataLoader, a query for 100 users' posts triggers 100 separate DB queries. DataLoader coalesces them into one.

### Q: Are GraphQL errors safe to return to clients? A: No — by default, Apollo Server includes stack traces in development errors. In production, set includeStacktraceInErrorResponses: false and use an error formatting plugin to strip internal details before sending responses.

### Q: Can I use REST-style API keys with GraphQL? A: Yes. Pass the API key in the Authorization header and validate it in your context function. The transport layer (HTTP) is separate from the query language (GraphQL).

### Q: Is GraphQL more or less secure than REST? A: Neither is inherently more secure. GraphQL has unique vectors (introspection, complexity attacks, batching) while REST has its own (IDOR, verb tampering). Both require deliberate security investment.


Conclusion

GraphQL security in 2026 requires disabling introspection in production, enforcing depth and complexity limits, rate limiting at both HTTP and field levels, implementing field-level authorization in every resolver, and defending against batching attacks. These are not optional hardening steps — they are baseline requirements.

After configuring your GraphQL server, verify the live endpoint with ZeriFlow's free scanner to confirm introspection is disabled, security headers are present, and your TLS configuration is sound.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading