Harden a Django GraphQL API: Five Classic Flaws
You've inherited Acme Social, a small Django app whose API is served by Strawberry. It works — and it ships with five weaknesses that show up again and again in real GraphQL services. The code is a perfectly ordinary Strawberry/Django setup; the defaults are the problem.
Based on: Intigriti's Five easy ways to hack GraphQL targets, though the lab doesn't follow the article step for step. It keeps the introspection, field-suggestion and alias techniques. It shows denial of service with deeply nested queries instead of batched ones. And it swaps the article's CSRF section for two production-readiness checks, because this demo API has no login for a CSRF attack to abuse. For the wider security principles behind the exercises, see GraphQL's security guide.
Here's the path a request takes through this lab:
curl / browser
│ HTTP GET or POST /graphql/ with a GraphQL query or mutation
▼
Uvicorn server (port 8000)
▼
Django: route request, apply the POST rate limit
▼
Strawberry GraphQL view: parse and validate against the schema
▼
Execute fields → resolvers → Django ORM → SQLite database
│
└── JSON response (data or errors) ──→ curl / browser
GraphQL validation can reject a query before any resolver runs.
Your job: confirm each weakness from the terminal with curl and jq (step 3
also uses the Acme Social browser tab), then fix it in the real project
under ~/graphql-lab. After each fix, restart the
service so your changes take effect, and the lab will re-check the live
endpoint:
sudo systemctl restart graphql-lab.service
It takes a couple of seconds to come back up. If it doesn't (a typo in your
edit, say), sudo systemctl status graphql-lab.service shows why.
The project you edit:
~/graphql-lab/graphql_lab/schema.py— where the schema is built.~/graphql-lab/graphql_lab/urls.py— where the view is wired up.
Nothing in the code is commented to tell you what to change — that's the point. Everything you need is below.
1. Introspection is wide open
GraphQL introspection lets a client download the entire schema. Handy in development, a gift to an attacker in production.
curl -fsS -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={__schema{queryType{name} types{name kind fields{name}}}}' \
| jq .
-G turns --data-urlencode into a URL query string, so this is a plain
GET /graphql/?query=.... If introspection is on, the response lists every
type and every field.
Disable introspection in the schema, then restart the service.
Hint
Strawberry ships a schema extension for exactly this. Extensions are passed
to strawberry.Schema(..., extensions=[...]). Look at
Strawberry's DisableIntrospection documentation.
2. Error messages suggest real field names
Turning off introspection isn't enough. Ask for a field that almost exists and the server helpfully corrects you — rebuilding the schema one guess at a time. GraphQL's security guide explains the risk of revealing field names through error messages; the Clairvoyance tool linked in the Intigriti article automates that kind of schema discovery.
curl -fsS -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={ usrs { id } }' \
| jq -r '.errors[].message'
There's no usrs field, so the query fails. Watch the error message for
Did you mean ...?
Make the server stop suggesting field names, then restart the service. Keep the
Cannot query field validation error readable; the goal is to remove the
suggestion, not to replace every validation error with a generic message.
Hint
This one is a schema config flag, not an extension. See
Strawberry's disable_field_suggestions documentation.
3. Development defaults are still switched on
Two Strawberry defaults help while you build an API, but give away too much once it's live:
- The GraphiQL IDE. Open
/graphql/in a browser and Strawberry serves an interactive query editor with autocomplete and a schema explorer. Anyone who finds the endpoint gets a ready-made tool for probing it. - Raw resolver errors. When code inside a resolver raises an exception, Strawberry sends the exception's message back to the client unchanged.
Open the Acme Social tab, which shows the app's front page, and follow the
/graphql/ link. You land in GraphiQL. The browser gets the IDE because it
asks for HTML, so send the same kind of request from the terminal:
curl -s -o /dev/null -w 'HTTP %{http_code}\n' \
-H 'accept: text/html' http://127.0.0.1:8000/graphql/
HTTP 200 means the IDE page is served.
Now look up a post by ID, once with a malformed ID and once with an ID that doesn't exist:
for id in abc 999; do
curl -fsS -G http://127.0.0.1:8000/graphql/ \
--data-urlencode "query={ post(id: \"$id\") { title } }" \
| jq -r '.errors[].message'
done
Field 'id' expected a number but got 'abc'.
Post matching query does not exist.
Both messages are Django's own wording, raised by the resolver's
Post.objects.get(...) call. They tell an attacker the API runs on Django's
ORM, and they name a model (Post) and a model field (id) with its type. A
client only needs to know that the lookup failed.
Make two changes, then restart the service:
- Switch off the IDE where the view is wired up, in
graphql_lab/urls.py. - Mask resolver errors in
graphql_lab/schema.py. Mask only errors raised inside resolvers. GraphQL's own validation errors, such as a misspelled field or the limits you'll add in steps 4 and 5, describe the client's query rather than your code, so they should stay readable.
Masking hides the details from clients, not from you: the full exception still
goes to the service log, and sudo systemctl status graphql-lab.service shows
its last lines.
Once both changes are live, the IDE request returns HTTP 404 (the tab's
/graphql/ link shows Not Found) and both lookups print Unexpected error.
The check also confirms that a misspelled field still gets its validation
message and that a lookup with a real ID still works.
Hint 1
The IDE is an option of the view, not the schema: GraphQLView.as_view(...)
accepts graphql_ide=None. See the options in
Strawberry's Django integration documentation.
Hint 2
Another extension: MaskErrors from strawberry.extensions. On its own it
masks every error, validation errors included, so give it a rule. Only
exceptions raised inside resolvers carry an original_error:
MaskErrors(should_mask_error=lambda error: error.original_error is not None).
See Strawberry's MaskErrors documentation.
4. Field aliases bypass the rate limiter
The endpoint has a per-request rate limiter (60 POSTs a minute per client
address), so a brute-force of the verifyOtp mutation should be slow. But
GraphQL lets one request
alias the same field many times —
and the limiter counts that as a single request.
First build one mutation that packs 60 OTP guesses, each under its own alias
(a0, a1, ...):
Q="mutation{ $(for i in $(seq 0 59); do printf 'a%d: verifyOtp(code:"%04d"){ ok } ' "$i" "$i"; done)}"
echo "$Q"
Then send it as a single POST:
curl -s -X POST http://127.0.0.1:8000/graphql/ \
-H 'content-type: application/json' \
--data "$(jq -nc --arg q "$Q" '{query: $q}')" \
| jq '{attempts: (.data | length), errors: (.errors | length)}'
jq -n --arg wraps the query in a JSON request body and handles the quoting.
attempts: 60 means all 60 guesses ran inside one rate-limited request.
GraphQL's security guide recommends limiting aliases within an
operation.
Strawberry provides a built-in alias limiter, but in this exercise you'll
write a small graphql-core validation rule that counts aliased fields and
rejects a request with too many. Define the rule in graphql_lab/schema.py,
register it on the schema, then restart the service.
Hint 1
Subclass graphql.validation.ValidationRule. Implement enter_field(self, node, *_), count the ones where node.alias is set, and call
self.report_error(GraphQLError(...)) past your threshold.
Return BREAK (from graphql.language) after reporting so your rule emits
only one error for an oversized request.
Hint 2
Register the rule with the
AddValidationRules([...]) extension
(from strawberry.extensions). Strawberry also ships
MaxAliasesLimiter
if you'd rather use the built-in — but writing the rule is the point here.
5. A single deep query can exhaust the server
Post.author and User.posts reference each other, so a query can nest as
deep as a client likes. With no depth limit, one request can force an enormous
amount of work. GraphQL's security guide recommends limiting operation
depth.
curl -s -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={ me{ posts{ author{ posts{ author{ posts{ id }}}}}} }' \
| jq '{data_present: (.data != null), errors: (.errors | length)}'
data_present: true with no errors means the server resolved the whole chain
(vulnerable). A depth-limit error means it refused (fixed).
Choose a cap that still allows the app's normal pages. The calculation below shows how to count their depth and compare it with the probe.
Work out the limit
A depth limit applies to every query, including the ones the app's own pages send. Set it too low and real pages break; set it too high and the probe gets through. The right value is the highest depth this application is likely to need. Work it out in three steps.
1. Learn how depth is counted. Strawberry's QueryDepthLimiter follows the
longest path from the query root and counts its fields, except the leaf
field at the end. Braces do not add depth. The probe above is
me (1) → posts (2) → author (3) → posts (4) → author (5) → posts (6) → id,
so it has depth 6.
2. Count the depth of the queries the app needs. Acme Social's pages send queries like these:
- Profile page — your name and your post titles:
{ me { username posts { title } } }isme (1) → posts (2) → title, depth 2. - Post page — the post, its author, and the author's other posts:
{ post(id: "1") { title author { username posts { title } } } }ispost (1) → author (2) → posts (3) → title, depth 3.
3. Find the highest depth the app is likely to need. Try going one level
past the post page: post → author → posts → author. That last author is the
author of the author's own posts, the same user the query already fetched at
level 2. Every level past 3 just goes round the posts → author loop again and
returns users and posts the query already has. So no sensible page needs more
than depth 3: that is the highest depth this application is likely to need.
That number is your limit: max_depth=3. It allows both pages and rejects
the depth-6 probe. A lower cap breaks real pages: max_depth=1 rejects the
profile page and max_depth=2 the post page, which is why you can't simply set
the smallest possible limit. If Acme Social later adds a page that needs deeper
data, redo the count and raise the limit to match.
Add the limit to the schema, then restart the service. Then check both sides: the probe above should now get a depth-limit error, and the post page should still return data:
curl -s -G http://127.0.0.1:8000/graphql/ \
--data-urlencode 'query={ post(id: "1") { title author { username posts { title } } } }' \
| jq .
The check sends both queries.
Hint
Another extension: QueryDepthLimiter(max_depth=...) from
strawberry.extensions. See Strawberry's QueryDepthLimiter documentation.
Pass it the depth you worked out above.
Done
You've taken a stock GraphQL service and closed five weaknesses: introspection, field suggestions, development defaults left on (the GraphiQL IDE and raw resolver errors), alias-based rate-limit bypass, and unbounded query depth. Same service, hardened defaults.