Step 198. GraphQL/API Security — Enter Through One Door, Read the Whole Schema

Step 198. GraphQL/API Security — Enter Through One Door, Read the Whole Schema

Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★☆☆ | Estimated time: 3 hours

Prerequisites: you’ve finished Step 197 (Advanced JWT). You can read REST APIs and JSON responses, and you know the concept of IDOR (the Step 139 family).

  • What you need: Python 3 + Flask (python -m pip install flask), curl.
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
  • Legal practice grounds: the Flask API server you’ll run today is a local lab inside your own computer. PortSwigger Web Security Academy’s GraphQL labs and intentionally vulnerable GraphQL practice apps (DVGA, etc.) are legal platforms made to be solved. Do not use today’s techniques anywhere outside these two places.

Where a REST API gives you "fixed data per address," GraphQL lets the client write a query at a single endpoint (/graphql) saying "give me just this field and that field." Convenient for development — but it creates new surface for security. If introspection is on, the entire schema — which queries exist, which mutations exist, and what fields each exchanges — is exposed wholesale in a single request. And REST’s "per-address permissions" instinct doesn’t transplant cleanly, so many places have authentication missing at the field level.

Today we go in two branches. In the first half, you’ll learn GraphQL’s request structure, introspection, and finding hidden mutations through concepts and screen examples. In the second half, you’ll run a REST API yourself with Flask and measure how easily "field over-exposure" and "unauthenticated deletion" happen — GraphQL or REST, the skeleton of API security is the same.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Read the structure of a GraphQL query and explain how it differs from REST
  • Explain why an introspection query is "a full schema leak"
  • Know the attack flow of finding hidden mutations in the schema and calling them without authorization
  • Reproduce field over-exposure and unauthenticated methods in a Flask REST API
  • Build an API security checklist common to REST and GraphQL

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 + Flask (REST measurement), GraphQL as screen examples + PortSwigger Academy (wargame)
Today’s commands {"query": "..."}, { __schema { ... } }, curl -X DELETE, the dev tools Network tab
Concepts needed GraphQL queries/mutations, introspection, field-level authentication, IDOR, over-exposure
Today’s deliverables A vulnerable REST API server + GraphQL attack-flow notes + an API checklist

2-1. GraphQL — One Door, Data the Client Picks

In REST, to get user info you knock on an address like /api/v1/users/1 and receive JSON in the shape the server decided. GraphQL is different. The address is just /graphql, and the client writes the fields it wants as a query.

{"query": "{ user(id: 1) { name email } }"}

The server returns only name and email. It doesn’t send unneeded fields, so it’s efficient, and the frontend decides the shape it wants. Reads are called queries; writes and changes are called mutations.

2-2. introspection — The Query That Asks for the Schema

A GraphQL server has a built-in feature for describing itself. This is introspection.

{"query": "{ __schema { types { name fields { name } } } }"}

This one line returns the list of every type and field on the server. It was made for developer tools (autocomplete, documentation generation), but to an attacker it’s "a list of every command that can be attacked." Useful in development environments; left on in production, it finishes the reconnaissance stage in one shot.

2-3. Field-Level Authentication — Why the REST Instinct Fails

In REST, permission checks are usually "per address." /admin is admins-only. In GraphQL, every request enters through /graphql, and the real work is decided by the fields inside the query. Asking for user(id: 2) and executing deleteUser(id: 2) come in through the same door. So authentication must be designed "per field" — and many places skip this. The result is the modern edition of IDOR: accidents where anyone logged in can call other people’s objects and admin-only mutations repeat constantly.

2-4. REST’s Chronic Diseases — Over-Exposure and Method Neglect

REST isn’t safe either. The screen needs only name and email, but if a developer returns the whole DB object as JSON, fields like password hashes and national ID numbers ride out too (over-exposure). And if an endpoint a developer built thinking only of GET actually also accepts DELETE, an undocumented deletion becomes possible (method neglect). You’ll reproduce both yourself in Section 3.


3. Follow Along

3-1. Learning the GraphQL Query Structure (Screen Example)

Without installing a GraphQL server library, first learn the shapes of request and response. Below is a typical exchange you’d see in a PortSwigger lab, as a screen example.

Request:

{"query": "{ user(id: 1) { name email } }"}

Response example:

{"data": {"user": {"name": "gildong", "email": "gildong@example.com"}}}

How to read it: only the fields written in the request come back in the response. What if you additionally write password? If the server hasn’t put authentication on that field, the password hash comes back as-is. "I decide the request’s shape" is GraphQL’s power — and the attack’s entrance.

3-2. Receiving the Whole Schema via introspection (Screen Example)

{"query": "{ __schema { types { name fields { name } } } }"}

Response example (screen example — varies by lab environment):

{"data": {"__schema": {"types": [
  {"name": "User", "fields": [{"name": "id"}, {"name": "name"}, {"name": "email"}, {"name": "password"}]},
  {"name": "Mutation", "fields": [{"name": "deleteUser"}, {"name": "createPost"}]}
]}}}

How to read it: two harvests. First, the fact that the User type has a password field — that it exists. Second, the fact that a mutation called deleteUser exists. Features that appear nowhere on screen are written in the schema. If introspection is blocked, the fallback is field-name guessing — collect the queries the app actually sends from the dev tools Network tab and mutate the field-name patterns (getUserdeleteUser?).

3-3. Calling a Hidden Mutation (Screen Example)

Call the mutation you found in the schema, without authorization.

{"query": "mutation { deleteUser(id: 2) { success } }"}

Response example (screen example):

{"data": {"deleteUser": {"success": true}}}

How to read it: if it succeeded, field-level authentication is missing. A structure where any logged-in user can delete someone else’s account — the same disease as REST’s IDOR, but discovery is often delayed because "every request goes to the same address." The only defense is checking permissions inside each field/mutation’s resolver (the function that actually pulls the data).

3-4. Reproducing It in REST — Over-Exposure and Method Neglect (Measured)

Now let’s run it ourselves and see how the same diseases arise in REST. Write step198_restapi.py.

from flask import Flask, jsonify

app = Flask(__name__)
app.json.ensure_ascii = False

USERS = {
    1: {"id": 1, "name": "gildong", "email": "gildong@example.com",
        "pw_hash": "5f4dcc3b5aa765d61d8327deb882cf99", "ssn": "901010-1******", "role": "user"},
    2: {"id": 2, "name": "admin", "email": "admin@example.com",
        "pw_hash": "21232f297a57a5a743894a0e4a801fc3", "ssn": "800101-1******", "role": "admin"},
}

# Vulnerability 1: field over-exposure — the screen needs only the name, but the whole DB object is returned
@app.route("/api/v1/users/<int:uid>")
def get_user(uid):
    user = USERS.get(uid)
    if not user:
        return jsonify({"error": "not found"}), 404
    return jsonify(user)  # pw_hash, ssn, and role all exposed

# Vulnerability 2: no method validation — the developer thought only of GET, but DELETE is also accepted
@app.route("/api/v1/users/<int:uid>/delete", methods=["GET", "POST", "DELETE"])
def delete_user(uid):
    if uid in USERS:
        del USERS[uid]
        return jsonify({"deleted": uid})
    return jsonify({"error": "not found"}), 404

if __name__ == "__main__":
    app.run(port=5497)

Start the server (python step198_restapi.py) and send requests.

curl "http://127.0.0.1:5497/api/v1/users/1"     # my info
curl "http://127.0.0.1:5497/api/v1/users/2"     # someone else's info (IDOR)
curl -X DELETE "http://127.0.0.1:5497/api/v1/users/2/delete"   # deletion attempt
curl "http://127.0.0.1:5497/api/v1/users/2"     # confirm deletion

Output (measured 2026-09-09):

[My info lookup]
{"email":"gildong@example.com","id":1,"name":"gildong","pw_hash":"5f4dcc3b5aa765d61d8327deb882cf99","role":"user","ssn":"901010-1******"}
[Someone else's info (IDOR)]
{"email":"admin@example.com","id":2,"name":"admin","pw_hash":"21232f297a57a5a743894a0e4a801fc3","role":"admin","ssn":"800101-1******"}
[Account deletion attempt via DELETE]
{"deleted":2}
[Lookup after deletion]
{"error":"not found"}  HTTP 404

How to read it: three diseases visible at once. (1) A screen that needs only the name received pw_hash and ssn too — over-exposure. (2) We read user 2’s (admin’s) info with no login — IDOR. (3) An undocumented DELETE went straight through and the account was deleted — method neglect. Exactly the same root as GraphQL’s field-level authentication problem: the server didn’t check, per item, "does this requester have rights to this data / this action."

3-5. The Defense — Assembling Responses from ‘Only the Needed Fields’

Let’s fix the same functionality safely. The core is two lines.

PUBLIC_FIELDS = ("id", "name", "email")
SESSION_USER = 1  # my id, assuming I'm logged in

@app.route("/api/v1/users/<int:uid>")
def get_user(uid):
    if uid != SESSION_USER:                 # authorization: reject others' info
        return jsonify({"error": "forbidden"}), 403
    user = USERS.get(uid)
    if not user:
        return jsonify({"error": "not found"}), 404
    return jsonify({k: user[k] for k in PUBLIC_FIELDS})  # assemble only the needed fields

Why: the habit of handing over the whole object with jsonify(user) is the starting point of over-exposure. Responses should always be newly assembled from "the fields this API promises," and the permission check goes right after route entry. In GraphQL, this check must go into every resolver.

3-6. Connecting to the PortSwigger GraphQL Labs

The order in the Academy’s GraphQL labs is exactly today’s flow. (1) Collect the queries the app sends from the Network tab to learn the endpoint and field names, (2) send an introspection query to see the whole schema, (3) call the hidden fields/mutations found in the schema via Burp Repeater. If introspection is blocked, guess with a field-name dictionary. Check the REST side in parallel too — the classic three are version paths (internal paths like /v1/admin that /v2 lacks), method tampering (only GET documented, but PUT/DELETE also accepted), and attribute over-exposure.


4. Missions & Exercises

Mission — From API Vulnerability Reproduction to Defense

  1. Run the REST API from 3-4 and capture the three scenes: over-exposure, IDOR, DELETE deletion
  2. Apply the defense code from 3-5 and confirm that requesting someone else’s info returns 403 and that pw_hash is gone from the response
  3. Write a GraphQL introspection query on paper (or in a notepad) and list two things an attacker gains from the response
  4. Create a REST/GraphQL-common API security checklist of 5 or more items (e.g., per-field permission checks, introspection disabled in production, …)
  5. Solve one PortSwigger GraphQL lab and write a write-up

Exercises

Exercise 1. Explain, from an endpoint perspective, the structural reason GraphQL makes "authentication design harder" than REST.

Exercise 2. What does an attacker gain from a server with introspection on, and why is it dangerous even though it’s not immediately a breach?

Exercise 3. In the 3-4 measurement, which two problems did the single line jsonify(user) create at once?

Exercise 4. Name two fallback techniques for reconnoitering a GraphQL server whose introspection is blocked.


5. Model Answers & Completion Criteria

Mission Model Answer

Items 1–2 are exactly the Section 3 measurements. Expected results after the defense in item 2: GET /api/v1/users/2403 {"error": "forbidden"}; GET /api/v1/users/1 → response contains only id/name/email, with pw_hash, ssn, and role absent. Fix the DELETE endpoint by attaching authentication and an admin-permission check, or by narrowing methods outright.

Item 3: from an introspection response, the attacker gains (1) the list of all types and fields (a specification of attack targets) and (2) the mutation list (the existence of write/delete commands).

Item 4 checklist example:

# Checkpoint
1 Are permissions checked in every field/resolver (not relying only on route/endpoint-level checks)?
2 Are responses assembled from only the needed fields (no whole-object returns)?
3 Is introspection off in production?
4 Do endpoints reject undocumented HTTP methods?
5 Are internal paths (/v1/admin, etc.) unreachable from outside?

In the item 5 write-up, record "legitimate queries collected / introspection result summary / hidden fields·mutations called / the checks the server didn’t do."

Exercise Answers

Answer 1. REST splits functionality per address, so the simple instinct of "per-address permissions" works. In GraphQL, every request enters through /graphql and the actual work is decided by the fields inside the query, so permission checks must be pushed down to the field/resolver level. Without this design change, a login check alone opens every field.

Answer 2. The list of all of the server’s types, fields, and mutations — in other words, the full specification of attackable commands. The schema itself isn’t secret data, but it reveals the existence of admin features invisible on screen and the exact way to call them, dropping reconnaissance cost to nearly zero. Every later attack just follows this list — which is why it’s dangerous.

Answer 3. First, over-exposure: sensitive fields the screen doesn’t need (pw_hash, ssn, role) rode out in the response. Second, it opened a channel that combines with IDOR, becoming a structure that hands the contents of any uid regardless of the caller’s identity or ownership. The single idiom "return the DB object whole" is the shared root of both diseases.

Answer 4. First, collect the queries the frontend actually sends from the dev tools Network tab, learn the field names and structure, then mutate them. Second, guess with a field-name dictionary — if getUser is visible, try similar namings like deleteUser, users, admin, and judge existence from differences in error messages ("field doesn’t exist" vs "no permission").

Completion Criteria Checklist

  • [ ] I can explain GraphQL’s single endpoint and query structure
  • [ ] I can write an introspection query and state the attacker’s harvest from the response
  • [ ] I can explain the need for field-level authentication (resolver permission checks)
  • [ ] I reproduced over-exposure, IDOR, and method neglect in a local REST API
  • [ ] I confirmed 403 and the reduced-field response after the defense
  • [ ] I created a REST/GraphQL-common checklist of 5 or more items
  • [ ] Mission: defense implementation + solved one PortSwigger GraphQL lab

6. Common Pitfalls & Fixes

Wall 1. curl: (7) Failed to connect to 127.0.0.1 port 5497

Cause: the Flask server isn’t running.
Fix: keep python step198_restapi.py running in a terminal. If the port is already in use, change the port and the curl address together.

Wall 2. I sent a DELETE but got a 405

Symptom-family message:

405 METHOD NOT ALLOWED

Cause: a defended state — if DELETE isn’t in the Flask route’s methods, it’s rejected with 405. The vulnerable server in 3-4 deliberately left DELETE open.
Fix: seeing 405 means that endpoint has no method neglect. If you want the vulnerable reproduction, check methods=["GET","POST","DELETE"].

Wall 3. Korean characters in the response appear mangled as \uXXXX

Cause: Flask’s default JSON response uses ASCII escaping.
Fix: add app.json.ensure_ascii = False as in 3-4 and Korean prints as-is. Unrelated to security, but it makes captures more readable.

Wall 4. In the GraphQL lab, introspection gets a 400/rejection

Cause: introspection is off, per production recommendations. Not the lab blocking you — the defense applied.
Fix: switch to the fallbacks from Exercise 4’s answer — collecting real queries from the Network tab and guessing field names. "It’s blocked" is itself a valid reconnaissance result.

Wall 5. I added the defense code but pw_hash still appears in the response

Cause: code that returns the whole object like jsonify(user) remains, or instead of whitelist assembly you used a "remove only sensitive fields" approach (user.pop("pw_hash")), a structure that leaks whenever a new sensitive field gets added.
Fix: switch to the whitelist approach of "assembling fresh from allowed fields" as in 3-5. The removal approach gets breached again every time fields grow.


7. Summary

Today’s Concepts

Concept One-line explanation
GraphQL A query language where the client picks fields at a single endpoint
query / mutation Read commands / write·change commands
introspection The built-in feature for asking about the whole schema — if on, a specification leak
Resolver The function that actually pulls a field’s data — where permission checks belong
Field-level authentication The GraphQL-style design of checking permissions per field, not per route
Over-exposure The flaw of putting more fields in a response than needed
Method neglect An endpoint that also accepts HTTP methods not in the docs
Stealth recon Network-tab query collection + field-name guessing — the fallback when introspection is blocked

Today’s Commands & Code

Command/code What it does
{"query": "{ user(id:1){ name email } }"} GraphQL read query
{"query": "{ __schema { types { name fields { name } } } }"} introspection — the whole schema
mutation { deleteUser(id:2){ success } } Calling a write mutation
curl -X DELETE "http://127.0.0.1:5497/..." Checking method neglect
jsonify({k: user[k] for k in PUBLIC_FIELDS}) Whitelist assembly of response fields (defense)
methods=["GET","POST","DELETE"] A Flask route’s allowed-method list

The Instinct That Matters More Than Commands

When you see an API, ask two things. First, "do this response’s fields match only what this screen needs?" — if more comes back, that excess is the leak itself. Second, "can I send this request against someone else’s stuff?" — if changing just the id succeeds, it’s IDOR; in GraphQL, if changing just the field succeeds, it’s missing field-level authentication. GraphQL may look like new technology, but the diseases’ names are familiar: over-exposure, IDOR, missing permission checks. The only new thing is that there’s one door — and that’s exactly why checks must live at every field, not at the door.


Once every box is checked, Step 198 is complete. Click the checkbox in the sidebar to save your progress.