EncryptCodecencryptcodec
Blog/API Security
API SecurityMarch 29, 2026 · 8 min read

GraphQL Security: Disable Introspection and Prevent Query Attacks

GraphQL gives attackers something REST APIs do not: a built-in way to discover your entire schema. If introspection is enabled in production, you have handed them the blueprint.

The Introspection Problem

GraphQL introspection lets anyone query your schema — every type, field, mutation, and relationship:

{
  __schema {
    types {
      name
      fields {
        name
        type {
          name
        }
      }
    }
  }
}

This returns your entire API surface. An attacker now knows every query, mutation, argument, and type — including internal fields like isAdmin, passwordHash, or internalNotes that you never intended to expose.

Fix: Disable introspection in production.

// Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== "production",
});
# Strawberry (Python)
import strawberry
from strawberry.extensions import DisableIntrospection
 
schema = strawberry.Schema(
    query=Query,
    extensions=[DisableIntrospection()],  # blocks __schema and __type
)

Keep introspection enabled in development. Disable it in production. No exceptions.

Query Depth Attacks

GraphQL's nested query structure enables denial-of-service through deeply nested queries:

# This could resolve millions of database rows
{
  users {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts {
                  # ...20 levels deep
                }
              }
            }
          }
        }
      }
    }
  }
}

Fix: Limit query depth.

// Using graphql-depth-limit
import depthLimit from "graphql-depth-limit";
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(5)], // reject queries deeper than 5 levels
});

A depth limit of 5-7 covers most legitimate use cases. If a query needs to be deeper, restructure your schema.

Query Complexity Analysis

Depth limiting alone is not enough. A shallow but wide query can be equally expensive:

# Depth = 2, but fetches every user and all their data
{
  users(first: 10000) {
    email
    posts(first: 1000) {
      title
      body
    }
  }
}

Fix: Assign complexity scores to fields and reject queries that exceed a threshold.

// Using graphql-query-complexity
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
 
const complexityRule = createComplexityRule({
  maximumComplexity: 1000,
  estimators: [
    fieldExtensionsEstimator(),
    simpleEstimator({ defaultComplexity: 1 }),
  ],
  onComplete: (complexity) => {
    console.log("Query complexity:", complexity);
  },
});

Mark expensive fields in your schema:

const resolvers = {
  Query: {
    users: {
      extensions: { complexity: ({ args }) => args.first * 10 },
      resolve: (_, args) => fetchUsers(args),
    },
  },
};

Batching Attacks

GraphQL supports sending multiple operations in a single HTTP request. Attackers abuse this for brute-force attacks:

[
  { "query": "mutation { login(email: \"admin@co.com\", password: \"pass1\") { token } }" },
  { "query": "mutation { login(email: \"admin@co.com\", password: \"pass2\") { token } }" },
  { "query": "mutation { login(email: \"admin@co.com\", password: \"pass3\") { token } }" }
]

One HTTP request, 1000 login attempts. Your rate limiter counts it as one request.

Fix: Limit batch size or disable batching entirely.

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

If you need batching, limit it:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  allowBatchedHttpRequests: true,
  // Custom plugin to limit batch size
  plugins: [{
    async requestDidStart() {
      return {
        async didResolveOperation(ctx) {
          // Rate limit per operation, not per HTTP request
        }
      };
    }
  }],
});

Field-Level Authorization

A common mistake: checking auth at the query level but not at the field level.

type User {
  id: ID!
  name: String!
  email: String!        # any authenticated user can see this
  ssn: String!          # only the user themselves should see this
  salary: Float!        # only HR should see this
}

Fix: Implement field-level auth in resolvers or use directives.

const resolvers = {
  User: {
    ssn: (parent, args, context) => {
      if (context.user.id !== parent.id) {
        throw new ForbiddenError("Access denied");
      }
      return parent.ssn;
    },
    salary: (parent, args, context) => {
      if (!context.user.roles.includes("HR")) {
        throw new ForbiddenError("Access denied");
      }
      return parent.salary;
    },
  },
};

Or use a schema directive:

directive @auth(requires: Role!) on FIELD_DEFINITION
 
type User {
  id: ID!
  name: String!
  ssn: String! @auth(requires: SELF)
  salary: Float! @auth(requires: HR)
}

Persisted Queries

Instead of accepting arbitrary queries, whitelist only the queries your frontend actually uses:

// Client sends a hash instead of the full query
POST /graphql
{
  "extensions": {
    "persistedQuery": {
      "sha256Hash": "abc123..."
    }
  },
  "variables": { "id": "42" }
}

The server looks up the hash in a pre-registered query map. Unknown queries are rejected. This eliminates entire classes of attacks because attackers cannot craft custom queries.

Security Checklist

  • Disable introspection in production
  • Limit query depth to 5-7 levels
  • Implement query complexity analysis
  • Disable or limit batched queries
  • Add field-level authorization
  • Use persisted queries in production
  • Rate limit by operation, not by HTTP request
  • Log and monitor query patterns for anomalies

Conclusion

GraphQL is powerful, but its flexibility is a double-edged sword. Every feature that makes development faster — introspection, nested queries, batching — is also an attack vector. Lock them down before you ship to production. Disable introspection, limit depth and complexity, and never trust that authentication at the query level means authorization at the field level.

Share this post

Try the GraphQL Introspection Simulation