GraphQL introspection: disabling it leaves the field open

GraphQL introspection lets a client ask the server for its whole schema. Turning it off hides the field list, but a client that knows or guesses a field name can still call it. We sent 24 curl requests to a local GraphQL server with an unlisted admin field. With introspection on, a normal user listed the field and read another user's record. With introspection off the field still answered by name, and the error messages still suggested its name. An authorization check in the resolver blocked the read.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

What GraphQL introspection exposes, and the scope of this fixture

GraphQL introspection lets a client query a service for its own schema. The GraphQL specification says a service supports introspection over its schema and that the schema is queried using GraphQL itself. Tools use this to build documentation and autocomplete. An attacker uses the same query to list every field, including fields the public client never calls.

Our policy is small. Alice, Bob and an admin are users with synthetic records. Only the admin may call adminUserRecord(id), which returns any user's email and the last four digits of a made-up national ID. Ordinary users may call me. The public client uses only me, so nobody documented the admin field.

  • Target: four short-lived GraphQL servers bound to 127.0.0.1 on OS-assigned ports, built on graphql-core 3.2.14.
  • Requests: 24 POST requests to /graphql, six per server.
  • Identities: public demo bearer values for alice, bob and admin. They are fixed test strings with no value outside this fixture.
  • Data: three synthetic records in memory, with example.invalid addresses.

The vulnerable configuration is insecure on purpose, so the server binds only to loopback. Test a real system only with the owner's written permission; the Acceptable Use Policy describes authorized use. This article gives no permission to test any other system.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. The fixture also needs graphql-core, so the runner builds a temporary virtual environment, installs the pinned version 3.2.14 from PyPI into it and deletes it afterwards. Nothing is installed globally. Read the source, then run:

python3 verify.py --output rerun

The output directory must be new. Each curl request bypasses proxies, has a one-second connection timeout and a three-second total timeout, and does not follow redirects. The four server configurations are:

  • A, vulnerable: introspection on, no check in the resolver.
  • B, introspection off: the schema query is rejected, no check in the resolver.
  • C, authorization check: introspection on, the resolver requires the admin role.
  • D, check and introspection off: both changes together.

Our recorded command was python3 verify.py --output recorded-run. It exited with {"checks": 24, "passed": true, "failures": []}. That means every response matched what we expected to see, including the intentionally vulnerable ones. The run started at 22:46 UTC on 10 November 2026 with fixture version 1.0.0, Python 3.15.2, graphql-core 3.2.14 and curl 8.7.1 on macOS ARM64. Ports, timestamps and hashes are in results.json. A rerun uses different ports and response dates.

Observed results: introspection off, field still callable

Every request in the table used the stated identity. Alice is an ordinary user, so the policy expects her call to adminUserRecord for Bob to fail. The authenticated cases all returned HTTP 200 because the fixture reports GraphQL errors in the body, as many servers do. The anonymous request returned 401.

Observed results from 24 local requests (HTTP status, then what the body contained)
RequestA: vulnerableB: introspection offC: authorization checkD: check and introspection off
Alice sends the introspection query200: lists adminUserRecord200: query rejected200: lists adminUserRecord200: query rejected
Alice misspells the field name200: error suggests adminUserRecord200: error suggests adminUserRecord200: error suggests adminUserRecord200: error suggests adminUserRecord
Alice calls adminUserRecord for Bob200: Bob's email and ID digits200: Bob's email and ID digits200: forbidden, data null200: forbidden, data null
Alice calls me200: her own email200: her own email200: her own email200: her own email
Admin calls adminUserRecord for Bob200: Bob's record200: Bob's record200: Bob's record200: Bob's record
Anonymous calls adminUserRecord401401401401

Row three is the finding. Configuration A leaks Bob's record. Configuration B, with introspection disabled, leaks the same record to anyone who already knows or guesses the name. Rows four and five are the positive controls: legitimate use still works after the fix, and the admin still reaches the field. The 401 row shows that authentication is present in all four configurations, so the gap is the authorization decision that comes after login.

The misspelling row is important if you rely on the introspection setting. In graphql-core 3.2.14, query validation adds a "Did you mean" hint to the unknown-field error, and the hint appeared in all four configurations. The OWASP GraphQL Cheat Sheet describes the same behavior and notes that attackers can still guess fields when introspection is off. Other servers and versions may differ; we tested only this one.

Verify the finding before you report it

In the recorded configuration B run, Alice's request used port 53294. This is the executed command with the body shown on its own line:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer demo-alice-token' \
  --data '{"query": "{ adminUserRecord(id: \"bob\") { id email ssn_last4 } }"}' \
  http://127.0.0.1:53294/graphql

The recorded response body is:

{"data": {"adminUserRecord": {"id": "bob", "email": "bob@example.invalid", "ssn_last4": "2222"}}}

This is a finding because the token belongs to Alice, whose role is user, and the response contains fields from Bob's record. A 200 status alone proves nothing for GraphQL, because errors travel inside a 200 response. Read the data member and compare it with a control, here the admin call that returns the same record and the me call that returns Alice's own email.

Record the query text, the identity, the owner of the returned record, the policy that should have denied it and the response fields. Here the impact is disclosure of one synthetic record. We did not rate severity for any real system.

Fix with resolver authorization, then retest

Disabling introspection is one line in graphql-core. It adds a validation rule that rejects schema queries:

rules = list(specified_rules)
if not self.server.introspection:
    rules.append(NoSchemaIntrospectionCustomRule)

Configuration B used that rule. It removed the schema listing and left the field callable. The graphql.org introspection guide describes disabling introspection in production as common attack-surface reduction and describes it as one part of a broader strategy that also includes authentication and authorization. The OWASP cheat sheet recommends disabling introspection and also says to validate in every resolver that the requester may view the data. The check that fixed this fixture is in the resolver:

def resolve_admin_user_record(root, info, id):
    if info.context["authz"] and ROLES[info.context["identity"]] != "admin":
        raise GraphQLError("forbidden", extensions={"code": "FORBIDDEN"})
    return USERS.get(id)

In configuration C the runner repeated the same six requests with this check on and introspection still enabled. Alice's call for Bob returned forbidden with data null. The admin and me controls still returned data, and the anonymous request still returned 401. Configuration D, with both changes, gave the same call results. In C the schema listing still named the field, and the call was still denied, so the resolver check alone protected it.

The check suits this toy policy, which has one role. A real service also needs per-object ownership, tenant membership and rules for nested fields and mutations. In the fixed configurations the unknown-field hint still names adminUserRecord. Once the resolver denies the call, that leak has low impact, but it still tells an attacker the field exists. The cheat sheet lists suggestions for mistyped fields among its introspection items; we did not test a change for that.

Inspect the evidence bundle

All bearer values and records are synthetic. The bundle includes full response text with dates and server headers. No production credentials or customer data were involved.

What this experiment does not cover

We tested one toy schema with one query field per role on one library version. We did not test mutations, subscriptions, nested types, batching, aliases, query depth, GraphiQL or other schema-exploration tools, other GraphQL servers, persisted queries, or real error-masking settings. graphql-core disables introspection with a validation rule. Other servers use different switches. Check yours by running the same six requests.

For a real test, list the fields in the schema, mark which roles may call each one, and send each field with a low-privilege account and a second account's object IDs. Test again with introspection off, because, as the OWASP sheet notes, attackers can still guess field names by brute force. Related reading: API authorization testing, broken access control and the OWASP API Top 10.

Excession Labs runs this kind of authorization testing in its security assessments, including checks of GraphQL fields with and without introspection. See how a finding like this looks in a sample report.