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.1on 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.invalidaddresses.
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.
| Request | A: vulnerable | B: introspection off | C: authorization check | D: check and introspection off |
|---|---|---|---|---|
| Alice sends the introspection query | 200: lists adminUserRecord | 200: query rejected | 200: lists adminUserRecord | 200: query rejected |
| Alice misspells the field name | 200: error suggests adminUserRecord | 200: error suggests adminUserRecord | 200: error suggests adminUserRecord | 200: error suggests adminUserRecord |
Alice calls adminUserRecord for Bob | 200: Bob's email and ID digits | 200: Bob's email and ID digits | 200: forbidden, data null | 200: forbidden, data null |
Alice calls me | 200: her own email | 200: her own email | 200: her own email | 200: her own email |
Admin calls adminUserRecord for Bob | 200: Bob's record | 200: Bob's record | 200: Bob's record | 200: Bob's record |
Anonymous calls adminUserRecord | 401 | 401 | 401 | 401 |
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
- Machine-readable results: all 24 cases with identities, queries, expected and observed outcomes, response filenames and hashes.
- Exact recorded curl commands: the ports refer to the finished run. Use the runner to create fresh servers.
- SHA-256 manifest: compare downloaded files with ours. This is an integrity check, not independent attestation.
- Instructions and limitations, server source and verification source.
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.