Web Security (OWASP Top 10:2025) Module 12 – APIs, WebSockets and GraphQL (OWASP API Security Top 10:2023)
GraphQL security: cost, authorisation and exposure
Secure a GraphQL API: measure the cost of a document before it runs, authorise below the resolvers, limit what the schema reveals and guard the HTTP layer.
What you will learn
- Explain why a GraphQL client chooses the cost of a request, and measure depth, aliases, page size and cost before it runs
- Apply depth, alias, page and cost limits, and choose between them with a table of what each one stops
- Build one business-layer policy that every resolver path asks, including node and nested fields
- Prevent schema leaks through introspection, field suggestions and verbose errors in production
- Handle batching, aliases, GET mutations and form content types at the HTTP layer, and decide when trusted documents fit
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why a GraphQL client chooses the cost of a request, and measure depth, aliases, page size and cost before it runs
- Apply depth, alias, page and cost limits, and choose between them with a table of what each one stops
- Build one business-layer policy that every resolver path asks, including node and nested fields
- Prevent schema leaks through introspection, field suggestions and verbose errors in production
- Handle batching, aliases, GET mutations and form content types at the HTTP layer, and decide when trusted documents fit
Before you start
On this page
A REST route has a price that the route fixes: the server decides what each URL returns. A GraphQL endpoint has one URL, and the client writes the shape of the answer. In this lesson’s first program a dashboard query costs one server 22 resolver calls, a nine-level query of 94 characters costs it 61,106, and a shallow request of 4,295 characters with 100 aliases costs it 10,600.
That freedom is the point of GraphQL, and it moves decisions from the route into the schema: what a request may cost, who may read each field, what the endpoint shows a stranger, and which HTTP requests may reach it. The answers, in short: measure a document before it runs, ask one policy for every field, and show a stranger as little as production allows.
One endpoint, one request, any shape
GraphQL is a query language for APIs. A client sends a document that names the fields it wants, and the server answers with exactly that shape. Fields can lead back to where they started: an author has books, and a book has an author. The schema allows that cycle, and nothing in the language limits how many times a client follows it. The program holds a library of 5 authors with 10 books each and a small GraphQL engine that counts every field resolved on every object as one resolver: The function that produces the value of one field of one object in a GraphQL schema. A document runs by calling resolvers, so the number of resolver calls measures what a request costs the server. call. It sends four documents to a weak server, which runs everything, and to a fixed one, which measures a document before it runs it.
// A small GraphQL engine, enough to show what a client can ask for. It reads the GraphQL language (operations, fields,
// aliases, arguments and nested selections; no fragments, variables or directives), measures a document, and runs it against a
// library of 5 authors with 10 books each. Every field resolved on one object counts as one resolver call. The weak server runs
// every document it receives. The fixed server measures a document first and refuses one that is too deep or has too many aliases.
function tokenize(text) {
const tokens = [];
const re = /[ \t\r\n,]+|#[^\n]*|([{}():])|(-?\d+)|("(?:[^"\\]|\\.)*")|([_A-Za-z][_0-9A-Za-z]*)/gy;
let pos = 0;
while (pos < text.length) {
re.lastIndex = pos;
const m = re.exec(text);
if (!m) throw new Error(`syntax error at character ${pos}`);
pos = re.lastIndex;
if (m[1]) tokens.push(m[1]);
else if (m[2] !== undefined) tokens.push({ int: Number(m[2]) });
else if (m[3] !== undefined) tokens.push({ str: JSON.parse(m[3]) });
else if (m[4] !== undefined) tokens.push({ name: m[4] });
}
return tokens;
}
function parse(text) {
const t = tokenize(text);
let i = 0;
const at = (p) => t[i] === p;
const expect = (p) => {
if (t[i] !== p) throw new Error(`expected ${p}`);
i += 1;
};
const name = () => {
const x = t[i];
if (!x || x.name === undefined) throw new Error('expected a name');
i += 1;
return x.name;
};
const value = () => {
const x = t[i];
i += 1;
if (x && x.int !== undefined) return x.int;
if (x && x.str !== undefined) return x.str;
if (x && x.name !== undefined) return x.name === 'true' ? true : x.name === 'false' ? false : x.name;
throw new Error('expected a value');
};
const selection = () => {
expect('{');
const fields = [];
while (!at('}')) fields.push(field());
expect('}');
if (fields.length === 0) throw new Error('empty selection');
return fields;
};
const field = () => {
let alias = null;
let fieldName = name();
if (at(':')) {
i += 1;
alias = fieldName;
fieldName = name();
}
const args = {};
if (at('(')) {
i += 1;
while (!at(')')) {
const key = name();
expect(':');
args[key] = value();
}
expect(')');
}
return { alias, name: fieldName, args, selection: at('{') ? selection() : [] };
};
const operations = [];
while (i < t.length) {
let type = 'query';
if (t[i] && (t[i].name === 'query' || t[i].name === 'mutation')) {
type = t[i].name;
i += 1;
if (t[i] && t[i].name !== undefined) i += 1; // the operation's name
}
operations.push({ type, selection: selection() });
}
return operations;
}
// How big a document is: its depth, its fields and its aliases, from the parsed form, before anything runs.
function measure(selection, depth = 1) {
const m = { depth, fields: 0, aliases: 0 };
for (const f of selection) {
const child = f.selection.length ? measure(f.selection, depth + 1) : { depth, fields: 0, aliases: 0 };
m.depth = Math.max(m.depth, child.depth);
m.fields += 1 + child.fields;
m.aliases += (f.alias ? 1 : 0) + child.aliases;
}
return m;
}
const AUTHORS = Array.from({ length: 5 }, (_, a) => ({ id: `a${a + 1}`, name: `Author ${a + 1}` }));
const BOOKS = AUTHORS.flatMap((author, a) => Array.from({ length: 10 }, (_, b) => ({ id: `b${a + 1}-${b + 1}`, title: `Book ${a + 1}.${b + 1}`, authorId: author.id })));
const BOOKS_OF = new Map(AUTHORS.map((author) => [author.id, BOOKS.filter((book) => book.authorId === author.id)]));
const AUTHOR_OF = new Map(AUTHORS.map((author) => [author.id, author]));
const RESOLVERS = {
Query: { authors: (_, args) => AUTHORS.slice(0, args.first ?? AUTHORS.length), author: (_, args) => AUTHOR_OF.get(args.id) ?? null },
Author: { id: (o) => o.id, name: (o) => o.name, books: (o, args) => BOOKS_OF.get(o.id).slice(0, args.first ?? 10) },
Book: { id: (o) => o.id, title: (o) => o.title, author: (o) => AUTHOR_OF.get(o.authorId) },
};
const RETURNS = { 'Query.authors': 'Author', 'Query.author': 'Author', 'Author.books': 'Book', 'Book.author': 'Author' };
function execute(selection, type, parent, stats) {
for (const f of selection) {
stats.calls += 1;
const resolver = Object.hasOwn(RESOLVERS[type], f.name) ? RESOLVERS[type][f.name] : undefined; // own fields only: not "constructor"
if (!resolver) throw new Error(`Cannot query field "${f.name}" on type "${type}"`);
const value = resolver(parent, f.args);
const childType = RETURNS[`${type}.${f.name}`];
if (!childType) continue;
for (const item of Array.isArray(value) ? value : [value]) if (item) execute(f.selection, childType, item, stats);
}
}
const LIMITS = { maxDepth: 6, maxAliases: 10 };
function weakServer(text) {
const stats = { calls: 0 };
for (const op of parse(text)) execute(op.selection, 'Query', null, stats);
return `ran, ${String(stats.calls).replace(/\B(?=(\d{3})+(?!\d))/g, ',')} resolver calls`;
}
function fixedServer(text) {
const ops = parse(text);
for (const op of ops) {
const m = measure(op.selection);
if (m.depth > LIMITS.maxDepth) return `refused: depth ${m.depth} is above ${LIMITS.maxDepth}`;
if (m.aliases > LIMITS.maxAliases) return `refused: ${m.aliases} aliases are above ${LIMITS.maxAliases}`;
}
return weakServer(text);
}
const documents = [
['a dashboard of 3 authors', '{ authors(first: 3) { name books(first: 5) { title } } }'],
['a profile page', '{ author(id: "a1") { name books { title author { name } } } }'],
['a cycle, nine levels deep', '{ authors { books { author { books { author { books { author { books { title } } } } } } } } }'],
['the same list 100 times', `{ ${Array.from({ length: 100 }, (_, n) => `l${n + 1}: authors { books { author { name } } }`).join(' ')} }`],
];
console.log(`${'document'.padEnd(28)}${'bytes'.padStart(6)}${'depth'.padStart(7)}${'aliases'.padStart(9)} ${'weak server'.padEnd(28)}fixed server`);
for (const [label, text] of documents) {
const m = measure(parse(text)[0].selection);
console.log(`${label.padEnd(28)}${String(text.length).padStart(6)}${String(m.depth).padStart(7)}${String(m.aliases).padStart(9)} ${weakServer(text).padEnd(28)}${fixedServer(text)}`);
} Output
document bytes depth aliases weak server fixed server a dashboard of 3 authors 56 3 0 ran, 22 resolver calls ran, 22 resolver calls a profile page 61 4 0 ran, 33 resolver calls ran, 33 resolver calls a cycle, nine levels deep 94 9 0 ran, 61,106 resolver calls refused: depth 9 is above 6 the same list 100 times 4295 4 100 ran, 10,600 resolver calls refused: 100 aliases are above 10
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node query_shapes.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The dashboard and the profile page cost 22 and 33 calls on both servers. The cycle of nine levels is a document of 94 characters, and the weak server made 61,106 calls to answer it. With real data behind it, each call can be a query or a row, and every two more levels multiply the work by ten. The last document is long and shallow, and its 100 aliases of one list cost 10,600 calls. The fixed server measured both documents before it ran anything and refused them with a reason a developer can read, and it still answered the two ordinary ones.
Six controls follow, for cost, authorisation, exposure and the HTTP layer; the rest of the lesson takes each in turn:
- Demand limits on depth, aliases, page size and cost refuse a document before any resolver runs.
- One authorisation policy, asked by every resolver, decides who may read each field and object.
- Exposure falls when introspection, field suggestions and error detail are off in production.
- A budget for each account, not each request, stops aliases from multiplying guesses.
- Refusing mutations by GET and bodies other than application/json stops cross-site forms from forging a request.
- Trusted documents let a production server run only the documents your team built.
Key idea
In GraphQL the client chooses the cost of a request, so the server has to measure a request before it runs it.
Checkpoint
Check: The client chooses the cost of a request
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why does GraphQL move security decisions into the schema?
A REST route has a price that the route fixes, but a GraphQL endpoint has one URL and the client writes the shape of the answer. The schema must therefore decide what a request may cost, who may read each field, what the endpoint shows a stranger and which HTTP requests may reach it. The client chooses the cost, so the server has to measure a request before it runs it.
-
How expensive can a short GraphQL document be?
In the lesson a dashboard query cost 22 resolver calls, a nine-level cycle query of 94 characters cost 61,106 (every two more levels multiply the work by ten), and a shallow request with 100 aliases of one list cost 10,600. The fixed server measured both before running anything and refused them with a readable reason, while still answering the ordinary documents.
-
Name the six controls the GraphQL lesson builds on.
Demand limits on depth, aliases, page size and cost, which refuse a document before any resolver runs; one authorisation policy that every resolver asks; introspection, field suggestions and error detail switched off in production; a budget for each account, not each request; refusing mutations by GET and bodies other than application/json; and trusted documents, so production runs only the documents your team built.
-
Why can a GraphQL schema let a short query explode in size?
Fields can lead back to where they started, since an author has books and a book has an author. The schema allows that cycle and nothing in the language limits how many times a client follows it.
Measure the document before it runs Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Authorise below the resolvers
- What the endpoint tells a stranger: introspection, hints and errors
- Batching, aliases and brute force
- GET, content types and cross-site requests
- Trusted documents: run only what you wrote
- Practise: measure a document, then refuse it
- Measure a GraphQL document and refuse it before it runs
- Interview questions
- Why can a small GraphQL request be expensive, and what do you limit?
- A penetration test guessed a 6-digit sign-in code through your GraphQL endpoint in under ten minutes, although the gateway allows 20 requests a minute for each address. How is that possible, and what do you change?
- A scanner reports that introspection is enabled in production. The team turns it off and closes the finding. What do you check before you agree that it is closed?
- A customer found that they could read other customers' orders through user { orders }, although the REST route and order(id:) were protected and tested. Another team added the GraphQL layer. How do you fix it, and how do you stop the next field from having the same problem?
- Key takeaways
1 diagram · 6 runnable examples (JavaScript) · 1 exercise with sample tests · 3 interview answers · about 44 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- introspection
- The GraphQL feature that lets a client ask a server for its own schema: types, fields and arguments. It helps tools and developers, and it hands a stranger a map of the API, so production servers usually turn it off.
- query cost
- An estimate of the work a GraphQL document will cause, made before it runs by giving each field a weight and multiplying it by the size of the list above it. A server refuses a document whose cost is above its budget.
- resolver
- The function that produces the value of one field of one object in a GraphQL schema. A document runs by calling resolvers, so the number of resolver calls measures what a request costs the server.
- trusted documents
- An allowlist of the GraphQL documents that a team wrote, reviewed and shipped with its clients, each stored under the hash of its text. A production server runs a document only when the request names one of these ids.
References
- GraphQL Cheat Sheet (OWASP Foundation)
- GraphQL specification (draft) (GraphQL Foundation)
- GraphQL: Security (GraphQL Foundation)
- GraphQL: Authorization (GraphQL Foundation)
- GraphQL over HTTP (6.2 Security) (GraphQL Foundation)
- ASVS 5.0.0, V4 API and Web Service (4.3.1 and 4.3.2, GraphQL) (OWASP Foundation)
- API4:2023 Unrestricted Resource Consumption (OWASP Foundation)
- DataLoader: create a new DataLoader for each request (Caching Per-Request) (GraphQL Foundation (graphql/dataloader))
- RFC 9110: HTTP Semantics (405, 413 and 415) (IETF (RFC Editor))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress