Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 6 of 30 minutes free
Free: One endpoint, one request, any shape. With a pass: the other 9 sections and 3 interview answers, about 24 minutes.

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.

Four documents sent to a weak GraphQL server and to one that measures first JavaScript · shapes/query_shapes.mjs
// 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

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

  1. Question 1 of 2 Who decides how much work a GraphQL document causes?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The client, by choosing the fields, the nesting and the page sizes

    On a REST route the server fixes the work. In GraphQL the client writes it, which is why the server must measure.

  2. Question 2 of 2 How did the fixed server refuse the nine-level cycle?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It measured the parsed document, found a depth of 9 against a limit of 6, and refused before any resolver ran

    Measure first, run second. Depth, aliases, page sizes and cost can all be read from the parsed document.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.