0x55aa
← Back to Blog

GraphQL Security: Your Schema Is a Treasure Map for Hackers πŸ—ΊοΈ

β€’6 min read

GraphQL Security: Your Schema Is a Treasure Map for Hackers πŸ—ΊοΈ

Here's a fun thought experiment: imagine printing your entire database schema, all your business logic relationships, every query and mutation your app supports, and taping it to the front door of your office. That's basically what GraphQL introspection does in production.

Hi, I'm Anuragh. I've spent 7+ years building production APIs and an embarrassing amount of time in CTF competitions and security communities. GraphQL security is one of those topics where the framework's greatest strength β€” its introspective, self-documenting nature β€” is also its biggest security footgun.

Let's talk about how attackers abuse GraphQL and how you actually stop them.

The Introspection Problem πŸ”

GraphQL ships with a feature called introspection β€” clients can query the API to learn its entire schema. Every type, every field, every relationship. Perfect for developer tooling. Catastrophic in production.

What an attacker does first:

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

That one query dumps your entire API surface area. Every "hidden" admin mutation, every internal field, every relationship you forgot about. In my experience building production systems, I've found APIs left with introspection enabled on the assumption that "nobody will find it." Spoiler: bug bounty hunters scan for this specifically.

The fix β€” one line:

const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== 'production',
});

Done. Next.

The Batching Attack: Free DoS With Extra Steps πŸ’₯

REST APIs let you hit one endpoint per request. GraphQL lets you hit all of them at once. This is where things get spicy.

The innocent-looking query that torches your database:

{
  user(id: 1) {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts { title } # keeps going...
              }
            }
          }
        }
      }
    }
  }
}

I once saw a production API take down its own database with a single query like this. Nobody had configured depth limits. The database query plan looked like someone had sneezed on it.

The fix:

import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(5),                     // no more than 5 levels deep
    createComplexityLimitRule(1000),   // complexity budget per query
  ],
});

5 levels of nesting is plenty for any legitimate use case. Beyond that? It's either a bug or an attack.

Authorization: The Field-Level Nightmare πŸ”

This one keeps me up at night. REST APIs are binary β€” you have access to the endpoint or you don't. GraphQL is surgical. Clients can request individual fields. Which means your authorization needs to be equally surgical.

The classic mistake (seen this in real code reviews):

type User {
  id: ID!
  name: String!
  email: String!
  internalAdminNotes: String   # πŸ‘ˆ anyone can ask for this
  creditCardLast4: String       # πŸ‘ˆ and this
  ssn: String                   # πŸ‘ˆ ...and this
}

The developer protected the resolver endpoint. But forgot that GraphQL exposes every field on every type to any authenticated user who just asks. Now your /graphql endpoint is simultaneously your public API and your internal admin API.

What you actually need β€” field-level authorization:

const resolvers = {
  User: {
    ssn: (parent, args, context) => {
      if (!context.user?.isAdmin) {
        throw new ForbiddenError('Not authorized');
      }
      return parent.ssn;
    },
    internalAdminNotes: (parent, args, context) => {
      if (!context.user?.isAdmin) {
        return null;
      }
      return parent.internalAdminNotes;
    },
  },
};

In security communities, we call this "object-level vs field-level authorization" β€” and most teams only implement the former. Every sensitive field needs its own permission check. Yes, it's tedious. No, there's no shortcut.

Injection via Resolver: Your Database Doesn't Care About GraphQL πŸ’‰

GraphQL is supposed to be injection-proof by design β€” typed variables instead of string concatenation. But watch what happens when developers go off-script inside resolvers:

The horror I've actually seen:

// Someone thought "flexible" meant "safe"
async searchUsers(parent, { query }, context) {
  // GraphQL typed the argument... but then we did THIS
  return db.raw(`SELECT * FROM users WHERE name LIKE '%${query}%'`);
}

GraphQL protects you at the HTTP layer. Your database has no idea GraphQL exists. If you're doing string interpolation inside resolvers β€” congrats, you've got SQL injection inside your "safe modern API."

The fix: Parameterized queries everywhere, always, no exceptions:

async searchUsers(parent, { query }, context) {
  return db('users').where('name', 'like', `%${query}%`); // Knex handles escaping
}

Real Talk: Rate Limiting That Actually Works 🚦

Here's a gotcha I hit personally while building an e-commerce backend: traditional rate limiting counts HTTP requests. One GraphQL request can contain 50 operations. Your "100 requests per minute" limit becomes "5000 operations per minute" if you're not careful.

A slow product search resolver (200ms each) seemed fine under normal rate limits. But GraphQL batching let someone send 50 search queries per HTTP request β€” effectively bypassing the rate limit by 50x. The backend went from "handling fine" to "on fire."

Rate limit operations, not just requests:

// In your middleware, before executing the query
const operationCount = document.definitions.length;
if (operationCount > 10) {
  throw new Error('Too many operations in a single request');
}

Or use an API gateway that actually understands GraphQL semantics. They exist. Use them.

Pro Tip: Test Your Own API First πŸ§ͺ

Before a bug bounty hunter does it for you, run these tools against your GraphQL endpoint:

  • graphql-cop β€” automated security checker for common misconfigs
  • InQL (Burp Suite extension) β€” purpose-built GraphQL security testing
  • GraphQL Voyager β€” visualizes what introspection leaks (great for demos to non-technical stakeholders who don't understand why it's bad until they see it)
# Quick security audit in one command
graphql-cop -t https://your-api.com/graphql

As someone passionate about security, I always say: find your own holes before the public bug bounty program does. It's less exciting but significantly cheaper.

The GraphQL Security Checklist βœ…

Before you ship that GraphQL API:

  • Introspection disabled in production
  • Query depth limit set (5-7 levels max)
  • Query complexity scoring configured
  • Field-level authorization on sensitive types
  • Parameterized queries in all resolvers
  • Rate limiting counts operations, not just requests
  • Batching limits configured (max operations per request)
  • graphql-cop run and clean

GraphQL is genuinely great β€” I use it in production and love it. But it requires thinking about security differently than REST. The schema is a feature and a liability. Treat it accordingly.


Building GraphQL APIs in production? Run into any fun batching attack war stories? Find me on LinkedIn or dig through my security experiments on GitHub. As someone active in security communities, I'm always down to swap notes. πŸ”