GraphQL Security: Your Fancy API Is Exposing Everything π΅οΈββοΈπ
GraphQL Security: Your Fancy API Is Exposing Everything π΅οΈββοΈπ
Here's a story that will make your stomach drop.
A startup ships a shiny new GraphQL API. Product loves it. Frontend devs love it. The CEO brags about it at a conference. Three weeks later, a security researcher sends a polite email: "Hey, I pulled your entire user database β including hashed passwords and billing info β using a single GraphQL query. Want the proof?"
The developer's response: "But... we checked for authentication?"
They did. Just in all the wrong places. π¬
GraphQL is genuinely brilliant. One endpoint, fetch exactly what you need, no over-fetching, killer developer experience. But that same flexibility that makes it delightful to build with makes it a treasure map for attackers if you're not careful. Let's talk about what goes wrong β and how to actually fix it.
The Introspection Trap πΊοΈ
GraphQL has a feature called introspection β you can query the API to learn its entire schema. Every type, every field, every relationship. It's fantastic for development tooling.
It's also fantastic for attackers.
# This one query reveals your entire API structure
{
__schema {
types {
name
fields {
name
type {
name
}
}
}
}
}
Run this against a production GraphQL endpoint with introspection enabled and you get a complete blueprint of your backend. Attackers use tools like GraphQL Voyager to visualize it as a beautiful interactive graph. They'll find fields you forgot you exposed β isAdmin, internalNotes, stripeCustomerId.
The fix: Disable introspection in production. Every major GraphQL library supports this:
// Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production', // Disable in prod
});
You can still allow introspection for authenticated admin users if needed β but it should never be public by default.
Query Depth Attacks: The Nested Nightmare π
GraphQL lets clients request nested data. Handy! Also a free DoS attack vector.
Imagine you have User β Posts β Comments β User β Posts β Comments... relationships. An attacker can craft a deeply nested query that forces your server to join dozens of tables, bringing your database to its knees:
# This innocent-looking query can destroy your server
{
user(id: "1") {
friends {
friends {
friends {
friends {
friends {
email
}
}
}
}
}
}
}
With five levels of nesting on a graph with thousands of users, you're potentially resolving millions of records. No rate limiting will save you if a single query can do this.
The fix β limit query depth and complexity:
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // Max 5 levels deep
createComplexityLimitRule(1000), // Max complexity score
],
});
Set your depth limit based on your actual schema needs. If your deepest legitimate query is 4 levels, set the limit to 5. Don't leave it at infinity "just in case."
The Authorization Blind Spot π
This is where most teams get burned. They implement authentication correctly β you need a valid JWT to hit the API β but forget that authentication β authorization.
GraphQL's flexibility means a single endpoint handles everything. Teams often add auth middleware at the transport layer (HTTP) and assume they're done. But GraphQL resolvers are just functions. If you don't check permissions inside each resolver, any authenticated user can access any data.
Classic mistake:
// β This checks "are you logged in?" not "can you see THIS user?"
const resolvers = {
Query: {
user: async (_, { id }, context) => {
if (!context.user) throw new Error('Not authenticated');
return db.users.findById(id); // Any user can fetch any other user!
},
},
};
// β
This actually checks authorization
const resolvers = {
Query: {
user: async (_, { id }, context) => {
if (!context.user) throw new Error('Not authenticated');
if (context.user.id !== id && !context.user.isAdmin) {
throw new Error('Forbidden'); // You can only fetch yourself (or be admin)
}
return db.users.findById(id);
},
},
};
For larger APIs, use a dedicated authorization library like graphql-shield to define permissions declaratively rather than sprinkling if checks everywhere.
Batching Attacks: Bypassing Rate Limits in Style π¨
Most developers add rate limiting to protect their API. Most developers don't know that GraphQL supports query batching β sending multiple operations in a single HTTP request.
// One HTTP request, twelve operations β your rate limiter sees this as... one request
[
{ query: '{ user(id: "1") { email } }' },
{ query: '{ user(id: "2") { email } }' },
// ... 10 more
]
Attackers use this to enumerate users, brute-force passwords, or scrape data while staying under per-request rate limits. Your 100 requests/minute limit becomes effectively 1200 operations/minute.
The fix: Rate limit at the operation level, not the HTTP request level. Disable batching entirely if you don't need it, or enforce a low batch size limit (3-5 operations max).
The Error Message Problem π£οΈ
GraphQL has great error handling. It's also very chatty by default. In development, detailed errors are invaluable. In production, they're a goldmine for attackers:
{
"errors": [{
"message": "duplicate key value violates unique constraint \"users_email_key\"",
"locations": [...]
}]
}
That error just told an attacker which email addresses are already registered β useful for targeted phishing or confirming which accounts to attack. Stack traces can expose file paths, library versions, and internal logic.
In production, return generic error messages to clients and log the full details server-side.
Quick Security Checklist β
Before you ship that GraphQL API:
- Disable introspection in production
- Set query depth limits (start at 5, adjust based on your schema)
- Set query complexity limits to prevent expensive queries
- Check authorization in every resolver, not just at the HTTP layer
- Rate limit at the operation level, not just per HTTP request
- Disable or limit query batching
- Sanitize error messages in production responses
- Use persisted queries in production (only allow known, pre-registered queries)
The Bottom Line
GraphQL isn't inherently insecure β it's powerful enough to be dangerous if you're not paying attention. The same flexibility that lets your frontend fetch exactly what it needs also lets attackers probe, enumerate, and extract data if you're not deliberate about your defenses.
The good news: all of these issues are fixable with a few hours of work. The bad news: most production GraphQL APIs I've seen have at least three of these problems. Go check yours right now.
Found a misconfiguration? Fixed something in this post? I'd love to hear about it β find me on Twitter/X or connect on LinkedIn. Let's keep building things that don't embarrass us in HackerNews post-mortems. π