A flexible query language also lets attackers write very expensive requests.
GraphQL gives clients a lot of power, so public endpoints need guardrails that REST APIs get for free from fixed routes.
Query cost. Nested lists multiply: users(first: 100) { friends(first: 100) { friends(first: 100) { name } } } asks for a million objects. Defences, usually as validation rules or server plugins:
- Maximum depth and maximum number of fields / aliases (aliases can repeat an expensive field many times in one request).
- Cost analysis: assign weights per field and multiply by list-size arguments; reject queries over a budget. Also limit batching (arrays of operations in one HTTP request).
- Execution timeouts and per-client rate limiting based on cost, not request count.
Trusted documents (persisted operations): for first-party clients, register every operation at build time and let the server accept only their hashes. This shuts down arbitrary queries entirely and shrinks request size. Automatic persisted queries (APQ) are a caching optimisation, not a security feature, since anyone can register a query.
Authorization belongs in the business layer and must be checked on every field or object that needs it: an object reachable through a nested path is just as exposed as a root field. Use the per-request context for the user identity.
Also: disable introspection and verbose errors only as defence in depth (the schema leaks through clients anyway), limit request body size, and protect against CSRF by requiring a JSON content type or a custom header on POSTs.
Example
import { buildSchema, parse, validate, GraphQLError } from "graphql";
function depthLimit(max) {
return (context) => {
let depth = 0;
return {
SelectionSet: {
enter(node) {
if (++depth === max + 1) context.reportError(
new GraphQLError(`Query depth exceeds ${max}`, { nodes: [node] }));
},
leave() { depth--; },
},
};
};
}
const schema = buildSchema(`type User { name: String friends: [User!]! }
type Query { me: User }`);
const deep = parse("{ me { friends { friends { friends { name } } } } }");
console.log(validate(schema, deep, [depthLimit(3)]).map((e) => e.message));
// [ 'Query depth exceeds 3' ]Remember
- Limit depth, aliases, batch size and estimated cost
- Trusted documents allow only pre-registered operations
- Authorize in the business layer, on every reachable object
- Hiding introspection is not a security boundary
Watch out for
- Checking permissions only on root fields while the same data is reachable through nested relations.