Skip to content
Fast-turnaround security assessments available — 10+ years development & security experienceGet started
vulnerabilityCWE-200OWASP A01:2021Typical severity: High

GraphQL Security: Introspection, Batching, and Authorization Pitfalls

·10 min read

GraphQL Security: Introspection, Batching, and Authorization Pitfalls

GraphQL replaced REST at hundreds of organizations because it solves real problems — clients fetch exactly the data they need, APIs evolve without versioning, and a single endpoint replaces dozens of resource-specific routes. These are genuine advantages. They also create a concentrated attack surface that behaves nothing like traditional REST APIs.

The difference matters for security. A REST API reveals its capabilities one endpoint at a time. You discover /api/users by guessing it exists, then discover its parameters by probing. GraphQL inverts this model entirely. By default, it will tell you everything — every type, every field, every mutation, every argument — if you simply ask.

This article covers the security risks that GraphQL introduces by design and the implementation mistakes that make them exploitable.

What GraphQL Exposes by Default

A fresh GraphQL deployment with default configuration exposes three capabilities that matter to an attacker:

A complete schema. The introspection system returns the full type hierarchy, including field names, argument types, return types, and relationships between objects. This is the equivalent of handing an attacker the database schema.

Arbitrary query composition. Clients construct their own queries. Unlike REST, where the server defines what data each endpoint returns, GraphQL lets the client specify exactly which fields to retrieve and how deep to traverse relationships.

Batched operations. A single HTTP POST can contain an array of queries or mutations. The server executes all of them and returns the results in one response.

Each of these features is intentional and useful. Each creates a distinct attack vector.

Introspection Queries and Schema Extraction

GraphQL's introspection system is a meta-API built into the specification itself. Any client can send an introspection query and receive the complete schema definition:

graphql
{
  __schema {
    types {
      name
      fields {
        name
        type {
          name
          kind
          ofType {
            name
          }
        }
        args {
          name
          type {
            name
          }
        }
      }
    }
  }
}

The response reveals every object type, every field on every type, and every argument every field accepts. In a typical application, this includes:

json
{
  "data": {
    "__schema": {
      "types": [
        {
          "name": "User",
          "fields": [
            { "name": "id", "type": { "name": "ID" } },
            { "name": "email", "type": { "name": "String" } },
            { "name": "role", "type": { "name": "UserRole" } },
            { "name": "internalNotes", "type": { "name": "String" } },
            { "name": "failedLoginAttempts", "type": { "name": "Int" } }
          ]
        },
        {
          "name": "Mutation",
          "fields": [
            { "name": "updateUserRole", "args": [
              { "name": "userId", "type": { "name": "ID" } },
              { "name": "role", "type": { "name": "UserRole" } }
            ]},
            { "name": "deleteAllUsers", "args": [] }
          ]
        }
      ]
    }
  }
}

Fields like internalNotes and failedLoginAttempts were clearly not intended for client consumption. Mutations like updateUserRole and deleteAllUsers reveal administrative capabilities. The attacker now has a complete roadmap of what to target, without sending a single malicious request.

Even when introspection is disabled at the application level, the schema can sometimes be partially reconstructed through field suggestion errors. Sending a query with a slightly misspelled field name may return a helpful error message:

json
{
  "errors": [{
    "message": "Cannot query field 'emal' on type 'User'. Did you mean 'email'?"
  }]
}

These suggestion messages leak field names one at a time, allowing patient enumeration even without full introspection access.

Query Batching for Brute-Force and Rate Limit Bypass

GraphQL servers typically accept arrays of operations in a single HTTP request. This is a feature — it reduces network round trips for legitimate clients. It is also an attack multiplier.

Consider a login mutation:

graphql
mutation {
  login(email: "target@example.com", password: "password123") {
    token
  }
}

An attacker can batch hundreds of these into a single request using aliases:

graphql
mutation {
  a1: login(email: "target@example.com", password: "123456") { token }
  a2: login(email: "target@example.com", password: "password") { token }
  a3: login(email: "target@example.com", password: "letmein") { token }
  a4: login(email: "target@example.com", password: "admin") { token }
  a5: login(email: "target@example.com", password: "welcome1") { token }
  # ... hundreds more
}

Or as a JSON array of operations:

json
[
  { "query": "mutation { login(email: \"target@example.com\", password: \"123456\") { token } }" },
  { "query": "mutation { login(email: \"target@example.com\", password: \"password\") { token } }" },
  { "query": "mutation { login(email: \"target@example.com\", password: \"letmein\") { token } }" }
]

Traditional rate limiting counts HTTP requests. One POST request with 500 login attempts inside it counts as one request. The attacker tries 500 passwords while consuming a single unit of rate limit quota.

This extends beyond credential attacks. OTP codes, coupon codes, referral codes — anything validated through a GraphQL mutation can be brute-forced at scale through batching. Account enumeration through password reset mutations becomes trivial when you can test thousands of email addresses in a single request.

Authorization Pitfalls

Authorization in GraphQL is where most implementations fail, and the failures tend to be systematic rather than one-off mistakes. The root cause is a structural mismatch between how GraphQL resolves data and how developers think about access control.

Resolver-Level vs. Data Access Layer Authorization

The most common pattern is checking permissions inside individual resolvers:

javascript
// Fragile: authorization at the resolver level
const resolvers = {
  Query: {
    user: (parent, { id }, context) => {
      if (!context.currentUser) throw new AuthError('Not authenticated');
      if (context.currentUser.id !== id && !context.currentUser.isAdmin) {
        throw new AuthError('Not authorized');
      }
      return db.users.findById(id);
    }
  }
};

This works until someone adds a new field or type that references user data through a different path:

javascript
// New resolver added months later — no authorization check
const resolvers = {
  Post: {
    author: (post) => {
      return db.users.findById(post.authorId);
    }
  }
};

Now any authenticated user can query any post and traverse to its author, bypassing the authorization check on the user query. The developer who added the Post.author resolver had no reason to know about the access control logic in Query.user.

The secure pattern moves authorization to the data access layer:

javascript
// Robust: authorization at the data access layer
class UserService {
  getUser(requestingUser, targetUserId) {
    if (!requestingUser) throw new AuthError('Not authenticated');
    if (requestingUser.id !== targetUserId && !requestingUser.isAdmin) {
      throw new AuthError('Not authorized');
    }
    return db.users.findById(targetUserId);
  }
}
 
const resolvers = {
  Query: {
    user: (_, { id }, ctx) => ctx.services.users.getUser(ctx.currentUser, id)
  },
  Post: {
    author: (post, _, ctx) => ctx.services.users.getUser(ctx.currentUser, post.authorId)
  }
};

Every path to user data goes through the same authorization check, regardless of which resolver triggers it.

Field-Level Authorization Gaps

Even when object-level authorization is correct, individual fields often lack access controls. A User type might include fields that only administrators should see:

graphql
type User {
  id: ID!
  name: String!
  email: String!
  role: UserRole!
  salary: Float          # HR only
  internalNotes: String  # Admin only
  lastLoginIP: String    # Security team only
}

If every authenticated user can query the User type, they can request any field on it. Without field-level authorization, a standard user query like this returns data it should not:

graphql
{
  user(id: "12345") {
    name
    salary
    internalNotes
    lastLoginIP
  }
}

Field-level authorization requires checking permissions per field, not just per type. This is where schema directives become valuable:

graphql
type User {
  id: ID!
  name: String!
  email: String!
  role: UserRole!
  salary: Float @auth(requires: HR)
  internalNotes: String @auth(requires: ADMIN)
  lastLoginIP: String @auth(requires: SECURITY)
}

Without this, every field on every type is accessible to every authenticated user — and sometimes to unauthenticated ones.

Nested Query Denial of Service

GraphQL schemas frequently define circular relationships. A User has Posts, each Post has Comments, each Comment has an Author (a User), who has Posts, and so on. Legitimate queries traverse one or two levels. Malicious queries go deeper:

graphql
{
  users {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts {
                  comments {
                    author {
                      name
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Each nesting level multiplies the number of database queries. With 100 users, 10 posts each, and 5 comments per post, the first three levels generate 5,000 records. Six more levels of nesting turn that into millions of database operations — from a single HTTP request.

This is not a volumetric attack. It does not require sending thousands of requests per second. A single well-crafted query can exhaust the database connection pool and bring the application to its knees.

Defenses

Disable Introspection in Production

Introspection should be disabled in every non-development environment. The implementation depends on the framework, but the principle is universal — production APIs should not describe themselves to arbitrary clients.

If field suggestion error messages are also leaking schema information, configure the server to return generic error messages instead of suggestions.

Enforce Query Depth and Complexity Limits

Set a maximum query depth — typically 7 to 10 levels is sufficient for legitimate use cases. Calculate query complexity based on the number of fields requested and the depth of nesting, and reject queries that exceed a defined threshold.

javascript
const validationRules = [
  depthLimit(10),
  costAnalysis({
    maximumCost: 1000,
    defaultCost: 1
  })
];

Rate Limit by Operation, Not by Request

Count individual GraphQL operations within a batch, not HTTP requests. If a single POST contains 500 mutations, that counts as 500 operations against the rate limit.

Better yet, implement cost-based rate limiting that accounts for query complexity. A simple query for a user's name costs less than a deeply nested query traversing multiple relationships.

Implement Authorization at the Data Access Layer

Move all authorization logic out of resolvers and into a centralized service or data access layer. Every path to a piece of data should pass through the same authorization check. Use schema directives for field-level visibility and ensure new types and fields inherit appropriate access controls by default.

Disable or Restrict Batching

If your application does not require query batching, disable it entirely. If batching is necessary for legitimate clients, limit the number of operations per batch to a reasonable number — 5 to 10 is typically sufficient.

Allowlist Known Queries

For applications with a known, finite set of client queries, persist those queries server-side and have clients reference them by ID instead of sending raw query strings. This eliminates arbitrary query composition entirely and prevents introspection, batching, and depth attacks simultaneously.

Key Takeaways

GraphQL's power comes from shifting control to the client. The client decides what data to fetch, how deep to traverse, and how many operations to batch. This flexibility is the feature, and it is the attack surface.

The defenses are not complex, but they require deliberate action. GraphQL's defaults favor developer convenience — full introspection, unlimited depth, unbounded batching, no field-level authorization. Every one of these defaults must be explicitly overridden for a production deployment.

REST APIs are insecure by accident. GraphQL APIs are insecure by default. The difference is that GraphQL tells you exactly what to fix — if you know to look.

Need your application tested for this? Get in touch.

Need your application tested?

We find these vulnerabilities in real applications every day. Get a comprehensive security assessment with detailed remediation.

Request an Assessment

Summary

GraphQL APIs expose powerful capabilities by default — full schema introspection, query batching, and deeply nested queries. When authorization is implemented at the wrong layer or introspection is left enabled in production, attackers gain a complete blueprint of your API and the tools to exploit it.

Key Takeaways

  • 1GraphQL introspection exposes the entire API schema including types, fields, arguments, and relationships — giving attackers a complete map of your data model
  • 2Query batching allows attackers to send hundreds of operations in a single request, bypassing rate limits and enabling credential stuffing at scale
  • 3Authorization implemented at the resolver level instead of the data access layer creates gaps where new fields and types inherit no access controls
  • 4Deeply nested queries can trigger exponential database load, enabling denial-of-service attacks without sending high volumes of traffic
  • 5Disabling introspection in production, enforcing query depth limits, and implementing field-level authorization are essential defenses

Frequently Asked Questions

GraphQL introspection is a built-in feature that allows anyone to query the API schema — every type, field, argument, mutation, and relationship. While useful during development, leaving it enabled in production gives attackers a complete map of your data model. They can identify sensitive fields like adminRole, internalNotes, or socialSecurityNumber without guessing, then craft precise queries to extract that data.

GraphQL accepts arrays of operations in a single HTTP request. An attacker can batch hundreds of login mutations into one POST request, each with a different password. Traditional rate limiting counts HTTP requests, not GraphQL operations, so the entire batch counts as one request. This enables credential stuffing and brute-force attacks at scale while appearing as normal traffic volume.

GraphQL allows queries to follow relationships to arbitrary depth. If a schema defines circular relationships — such as a user having posts, each post having comments, each comment having a user — an attacker can craft a query that nests these relationships dozens of levels deep. Each level multiplies the database queries, potentially generating millions of operations from a single request.

Authorization should be enforced at the data access layer or business logic layer, not at the resolver level. Resolver-level checks are fragile because new fields and types can be added without inheriting authorization rules. A centralized authorization layer ensures that every data access path — regardless of which resolver triggered it — enforces consistent access controls. Additionally, field-level visibility should prevent unauthorized users from even seeing sensitive fields in query results.