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:
{
__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:
{
"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:
{
"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:
mutation {
login(email: "target@example.com", password: "password123") {
token
}
}An attacker can batch hundreds of these into a single request using aliases:
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:
[
{ "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:
// 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:
// 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:
// 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:
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:
{
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:
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:
{
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.
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.