← All security check guides

Is a GraphQL API publishing its whole schema?

Introspection is the GraphQL feature that lets an endpoint describe itself. Ask it the right question and it returns every type, every field and every mutation it knows about, which is what makes developer tooling work: the schema explorer, the autocomplete in the editor and the generated client all read it. It is on by default in most GraphQL servers, and in production it hands that same description to anyone who asks.

What a published schema costs

A REST API has to be discovered a path at a time. A GraphQL API with introspection on answers one request with the complete map: the shape of every object, the name of every field, and every mutation the server will accept. Work that would have taken an afternoon of guessing becomes a document.

The interesting part is rarely the fields the website uses. It is the ones it does not: an admin mutation left in the schema, a user type carrying an internal note field, a resolver written for a migration and never removed. None of those appear on any page, so nothing but the schema announces they exist, and the schema announces them to everybody.

The published map also makes the next mistake findable. Authorization in GraphQL is enforced field by field, and a field where that was forgotten is invisible until somebody knows to ask for it. With the schema in hand, finding it is a matter of reading down a list rather than guessing at names.

Asked with one constant question

There is a version of this check that reads the customer's data, and it is easy to write by accident. Once the schema is in hand, running a query against it is one more line, and the results would make a far more dramatic report. It would also mean a scanner reading records it has no business reading, which is precisely the exposure the finding is about.

CheckWeb sends one GraphQL document and it is a compile-time constant: a bare request for the names of the schema's types. It selects no fields, takes no arguments and is not a mutation, so it reads none of the site's data and changes none of it. There is no code path in the scanner that can send any other GraphQL document, and a test asserts that over a full run.

The false-positive rule is equally narrow. A finding requires a parseable GraphQL response with a schema in it. An endpoint answering 400 or 403, one returning an errors document saying introspection is not allowed, and a site whose router answers every address with its own HTML all read as not enabled, which is what they are. A handful of conventional addresses are tried and no others, so this is a check rather than a search.

What comes back is counted and dropped. A schema is the design of somebody's API and a page of noise in a report, so the finding carries the fact that introspection is on and how many types were returned. Not one type name is recorded, and a test asserts that too.

How it looks in the report

The finding names the endpoint, the request that was sent, and the number of types the schema described. The count is what makes the result meaningful without reproducing it: a schema of a dozen types is a small public API, and one of several hundred is the whole application.

It sits at medium. A published schema is not a way in on its own, it is the step before one, and treating reconnaissance as a breach would misprice it. Where it matters most is alongside whatever else the report found on the same API.

One finding is reported per site rather than one per address. A project serving its schema at two paths has one setting to change, and two rows would make a single misconfiguration look like two problems.

Example

The GraphQL API describes its whole schema to anyone

Introspection is enabled in production, so the full list of types, fields and mutations is readable by anyone who asks. Turn it off in the production configuration and keep it on in development, where it is what makes the tooling work.

FAIL

GET /graphql (schema introspection), HTTP 200, a GraphQL schema naming 214 types. The schema itself was counted and discarded, not recorded.

Published vs private

Published

A default Apollo or Hasura deployment with introspection left as it shipped. Any client can load the full schema in a second, including the admin mutations that were never wired to a button and the fields left behind by an old migration. The application works perfectly, and nothing anywhere records that the map was taken.

Private

Introspection is disabled in the production configuration and enabled in development. An introspection request in production comes back as a validation error, the tooling still works locally, and the schema is documented for the team in the repository rather than on the public endpoint.

Turning introspection off is a speed bump, not a control. It is worth doing, and it is worth doing on the assumption that the schema will be worked out anyway: authorization has to hold on every field regardless.

How to turn it off

  • Switch it off in the production configuration rather than in the code path. Apollo Server takes introspection: false, graphql-js takes the NoSchemaIntrospectionCustomRule validation rule, and Hasura and similar managed services have a per-role switch. Gate it on the environment so development keeps the feature.
  • Disable the playground or explorer alongside it. A GraphQL IDE served in production is the same disclosure with a nicer interface, and it is frequently a separate setting from introspection itself.
  • Turn off field suggestions as well. Many servers answer an unknown field by naming the closest one that exists, which reconstructs a usable map of the schema one guess at a time and survives introspection being disabled.
  • Then check authorization on the fields the schema described, especially the ones nothing on the site calls. An API that is safe only because nobody has the map is not safe, and the admin mutation nobody remembered is the one this matters for.
  • Consider persisted queries if the API serves only a first-party client. Accepting a fixed set of known documents rather than arbitrary ones removes the whole class of problem instead of hiding its map.
  • Re-run the audit and confirm the endpoint answers an introspection request with a refusal. A setting applied to one environment, one service or one of several routes looks identical to one that shipped everywhere.

Run a free scan

The free passive scan grades headers, TLS and known CVEs on any site in about thirty seconds, with no signup. Verifying that a domain is owned adds the deep scan, which sends one constant introspection request to the conventional GraphQL addresses and reports the fact and the count, never the schema.

Check my website

This one needs an active scan

A passive scan reads what a site shows every visitor, and this finding is not in that layer. It takes an active audit, run by the owner on a domain they have verified, to look where it hides.

How the deep audit works →

FAQ

Is disabling introspection enough to secure a GraphQL API?
No, and treating it as the fix is the common mistake. It removes the convenient map; it does not remove the fields. Every field still needs its own authorization check, because a schema can be reconstructed from error messages and from any client that was ever shipped.
Will turning it off break the developer tooling?
Only in the environment it is turned off in. Gate the setting on the environment and local development keeps the explorer, the autocomplete and the generated types. Where a build step needs the schema, export it to a file in CI rather than reading it from production.
Does the scan run any queries against the data?
Never. One document is sent and it is a constant: a request for the names of the schema's types. It selects no fields and is not a mutation, so it reads nothing of the site's data and changes nothing.
Is the schema stored anywhere in the report?
No. What is kept is that introspection answered and how many types it named. No type name reaches the finding, the database or the PDF, and a test asserts it.

Related checks