Security¶
django-graphex ships opt-in GraphQL security middlewares and a small
schema helper to declare which fields are private — so you can block
introspection and require authentication on selected fields without rewriting
your resolvers.
DisableIntrospectionMiddleware— block schema introspection in production.AuthenticatedFieldsMiddleware— require an authenticated user on selected top-level fields.DjangoGraphQLSchema— declare the private fields next to the schema, with no settings duplication.
Looking for depth & cost limits?
Query-shape limiters (DepthLimitValidationRule, CostLimitValidationRule,
Meta.max_depth, Meta.complexity) now live on their own page —
Query depth & cost limits.
Runtime parser and validation hardening¶
The 3.1.1 candidate requires graphql-core 3.2.13. Its 3.2.12 fixes count
skipped comments when an application passes a max_tokens parser limit, and
bound the OverlappingFieldsCanBeMerged validation rule to 250,000 field
comparisons per document by default. Exhausting that budget returns a GraphQL
validation error before resolver execution. Version 3.2.13 additionally turns
truncated string escapes into GraphQL syntax errors; the HTTP view returns JSON
400 for these malformed documents. See the upstream
3.2.12 security notes
and 3.2.13 fix notes.
The built-in HTTP view calls parse(query) and does not pass max_tokens.
Thus the upstream comment accounting fix does not give the HTTP endpoint a
token-count limit. DJANGO_GRAPHEX['MAX_REQUEST_BODY_SIZE'] defaults to None;
set it to a finite byte limit appropriate for your deployment, and enforce
request-size limits at the ingress as well. Body size, parser tokens, validation
comparisons, and application query depth/cost are different controls; none
replaces all the others.
Wire the middlewares through DJANGO_GRAPHEX['MIDDLEWARE']:
DJANGO_GRAPHEX = {
"SCHEMA": "myapp.schema.schema",
"MIDDLEWARE": [
"django_graphex.security.DisableIntrospectionMiddleware",
"django_graphex.security.AuthenticatedFieldsMiddleware",
"django_graphex.middleware.GraphQLDirectiveMiddleware",
],
}
Disable introspection¶
DisableIntrospectionMiddleware blocks the introspection meta-fields __schema
and __type (so tools can't dump your schema), while leaving __typename
untouched.
Install the middleware in the GraphQL execution chain to enforce the setting.
ALLOW_INTROSPECTION=False is the default, but does not block introspection
on its own: the default MIDDLEWARE setting is empty. This configuration
blocks __schema and __type for non-superusers:
DJANGO_GRAPHEX = {
"MIDDLEWARE": ["django_graphex.security.DisableIntrospectionMiddleware"],
"ALLOW_INTROSPECTION": False, # default; enforced by the middleware above
"INTROSPECTION_ALLOW_SUPERUSER": True, # default: active superusers may introspect
}
| Setting | Default | Effect |
|---|---|---|
ALLOW_INTROSPECTION |
False |
Only enforced when DisableIntrospectionMiddleware is installed. When True, that middleware permits introspection for everyone. |
INTROSPECTION_ALLOW_SUPERUSER |
True |
With DisableIntrospectionMiddleware installed and ALLOW_INTROSPECTION=False, an active superuser (is_active and is_superuser) may introspect. |
A blocked introspection query returns an error; a missing context/user
(non-HTTP execution) is treated as non-superuser and does not crash. The bypass
requires an active account, exactly like every other superuser check in the
library (IsAdmin, the endpoint gate and the
permission-scoped schema): deactivating a
superuser revokes it immediately, even on authentication backends that do not
run Django's user_can_authenticate check (token / JWT).
"Did you mean" suggestions are stripped too¶
graphql-core answers a misspelled field, type or argument with the correct
spelling. That hint names real schema members, so probing with invented
names rebuilds much of the schema the middleware is there to hide:
{ user { emial } }
# introspection allowed:
# "Cannot query field 'emial' on type 'UserType'. Did you mean 'email'?"
# introspection disabled:
# "Cannot query field 'emial' on type 'UserType'."
Under GraphQL-core 3.3, the HTTP view asks native validation and execution to
omit schema-derived suggestions before they enter an error. That includes
input-field hints before a Found clause and variable-coercion hints; client
values, paths, and the Found detail remain intact. The current 3.2 runtime
continues to filter its recognized legacy trailing suggestion forms. The
locations and path fields are unchanged in either case.
Suppression is active only when introspection is actually disabled: it
needs DisableIntrospectionMiddleware in the effective middleware chain and
ALLOW_INTROSPECTION=False. With introspection open, native hints remain useful.
Under 3.3, SSE and WebSocket startup use the same private verdict for document
validation and variable coercion before framing errors. Their existing 3.2
formatter still covers recognized legacy trailing hints; it does not promise
suppression of every quoted unknown-input spelling in that older generation.
Superusers do not get the suggestions back
INTROSPECTION_ALLOW_SUPERUSER still lets an active superuser run
__schema, but the strip is deliberately request-independent: error bodies
are stored in the response cache, and a per-user decision there would serve
one caller's body to another.
Cross-site POST protection¶
The GraphQL endpoint is csrf_exempt — it authenticates each request explicitly
instead of relying on Django's session-cookie CSRF token. That is fine for
application/json, which a browser cannot send cross-site without a CORS
preflight. It is not fine for the CORS simple content types, which need no
preflight at all: a page on any origin can submit a <form> at your endpoint and
the browser attaches the victim's session cookie.
REQUIRE_CSRF_HEADER (default True) closes that hole. A POST whose content
type is one of
application/x-www-form-urlencodedmultipart/form-datatext/plain- (none at all — a body-less POST carrying its query in the query string)
must send the X-Requested-With header, or it is rejected with HTTP 403
before the body is read:
The header value is never inspected; XMLHttpRequest is the conventional one.
What matters is that X-Requested-With is not on the CORS safelist, so a
cross-origin request carrying it triggers a preflight the attacker page cannot
pass.
import requests
requests.post(
"https://app.example.com/graphql/",
files={"avatar": open("avatar.png", "rb")},
data={"query": "mutation { profileCreate(input: {}) { ok } }"},
headers={"X-Requested-With": "XMLHttpRequest"}, # <- required
)
| Setting | Default | Effect |
|---|---|---|
REQUIRE_CSRF_HEADER |
True |
When True, a POST of a CORS-simple content type must carry X-Requested-With. Set to False only if a client cannot add the header and the endpoint is protected another way. |
application/json and application/graphql clients change nothing
Neither is a CORS-simple content type, so both already force a preflight. GET requests and the GraphiQL page are unaffected as well.
The SSE subscription endpoint is
guarded by the same setting: it is csrf_exempt too and reads a form-encoded
query straight out of request.POST, so leaving it open would just move the
forged POST one URL over. Its refusal is the same HTTP 403 carrying the same
text, sent as a plain body rather than a JSON envelope — an EventSource
client does not parse one, and every other pre-stream rejection on that
endpoint (No GraphQL query provided., GraphQL syntax error: …) is a plain
body too.
Field-level authentication¶
AuthenticatedFieldsMiddleware requires an authenticated user
(request.user.is_authenticated) on the schema's private top-level fields,
raising a GraphQLError otherwise. It only enforces at the top level — nested
fields are never gated — and nothing is protected unless you declare it, so
adding the middleware is safe.
"Top level" is read from the resolve path (a top-level field has no parent
path segment), never from the root value: setting root_value on the view, as a
class attribute or through get_root_value() does not change what is gated,
and a field is still gated when it is reached through an inline fragment. A
selection nested under another field — including one inside a list element — is
not gated even if its name matches a protected top-level field.
The chain runs on every transport: the HTTP view, and — since 2.1.0 — the
SSE and WebSocket subscription transports, which build the same
DJANGO_GRAPHEX['MIDDLEWARE'] chain once per connection and apply it both to
the subscribe entry (before any group_add) and to each delivered event.
Fixed in 2.1.0
In 2.0.0 DJANGO_GRAPHEX['MIDDLEWARE'] was read only by GraphQLView.
Subscriptions are served only by the SSE / WS transports, so the middleware
never ran for them and private_subscription protected nothing: an
AnonymousUser could subscribe to a field reported in
gdx_protected_fields and receive its events.
Fixed after 2.1.0
Up to and including 2.1.0 the middleware used "the root value is not None"
as its proxy for "this is a nested field". Because root_value is a public,
documented seam, a view configured with one (GraphQLView.as_view(...,
root_value=...), the class attribute, or an overridden get_root_value())
lost all private-field protection: every protected field resolved for
anonymous callers. The same proxy also skipped the gate on every delivered
subscription event, since the event payload is the root value there.
A blocked field returns:
{
"errors": [{
"message": "Authentication required.",
"path": ["me"],
"extensions": {"code": "UNAUTHENTICATED", "status_code": 401}
}]
}
The private field set is resolved from, in order:
- the registry attached by
DjangoGraphQLSchema(recommended), or DJANGO_GRAPHEX["PROTECTED_FIELDS"]— a list of top-level field names, for setups that don't useDjangoGraphQLSchema.
# plain-schema setup, without DjangoGraphQLSchema
DJANGO_GRAPHEX = {"PROTECTED_FIELDS": ["me", "allOrders", "createOrder"]}
Declaring private fields: DjangoGraphQLSchema¶
The cleanest way to declare what is private is right where you build the schema.
DjangoGraphQLSchema accepts private_query,
private_mutation and private_subscription (all optional, all symmetric). Each
private_* root is unioned into its operation root, so you keep public and
private fields in separate roots: the schema exposes the union, and the
private ones require auth. Field names are collected and attached automatically —
no settings, no naming conventions, always in sync with the schema.
from graphql import GraphQLString
from django_graphex.directives import all_directives
from django_graphex.core import ObjectType, field
from django_graphex.schema import DjangoGraphQLSchema
from django_graphex.core.descriptors import NativeList
class PublicQueries(ObjectType):
server_time = field(GraphQLString)
class PrivateQueries(ObjectType):
me = field(UserType)
all_orders = field(NativeList(OrderType))
schema = DjangoGraphQLSchema(
query=PublicQueries, # public-only subset
private_query=PrivateQueries, # private-only subset (require auth)
mutation=PublicMutations,
private_mutation=PrivateMutations, # optional
subscription=PublicSubscriptions,
private_subscription=PrivateSubscriptions, # optional
directives=all_directives,
)
The schema's actual query root is the union of PublicQueries and
PrivateQueries — you don't build a combined root yourself. Field names are
matched against info.field_name (camelCase under the default
auto_camelcase=True), so all_orders protects allOrders.
Per-app modularity
Each app can expose its own Public* / Private* subsets; at the project
level aggregate them with multiple inheritance and pass the aggregates:
Subscriptions are symmetric
Subscriptions are treated exactly like queries and mutations: only the
fields in private_subscription are protected. A subscription without
private_subscription protects nothing. (A single full root plus a
private_* marker subset of names still works, for back-compat.)
Enforcement is symmetric too: the SSE and WS transports run the configured middleware chain, so a declared-private subscription field is denied at subscribe time and on every delivered event. (Before 2.1.0 it was declared but never enforced — see the danger note above.)
Add the middleware
If you pass private_query/private_mutation/private_subscription but
AuthenticatedFieldsMiddleware is not in DJANGO_GRAPHEX['MIDDLEWARE'],
DjangoGraphQLSchema emits a RuntimeWarning — the private fields would
otherwise go unprotected. (The check inspects DJANGO_GRAPHEX['MIDDLEWARE'];
middleware wired only via the view is not detected.)
Behavior matrix¶
Middleware in DJANGO_GRAPHEX['MIDDLEWARE'] |
Schema declares private fields | Result |
|---|---|---|
| ✅ | ✅ | declared fields require auth |
| ✅ | ❌ | everything public |
| ❌ | ❌ | everything public |
| ❌ | ✅ | everything public + RuntimeWarning |
Customizing: helpers and override points¶
AuthenticatedFieldsMiddleware exposes two override points:
from django_graphex.schema import collect_field_names
from django_graphex.security import AuthenticatedFieldsMiddleware
class MyAuthMiddleware(AuthenticatedFieldsMiddleware):
def get_protected_fields(self, info):
# build the set however you like (here, straight from your root types)
from myapp.schema import PrivateQueries, PrivateMutations
return collect_field_names(PrivateQueries, PrivateMutations)
def get_error_extensions(self, info, user):
ext = super().get_error_extensions(info, user) # {"code": "UNAUTHENTICATED", ...}
# enrich, e.g. surface a JWT failure reason your auth layer recorded
reason = getattr(getattr(info, "context", None), "auth_failure_reason", None)
if reason:
ext["reason"] = reason
return ext
collect_field_names(*object_types, camelcase=True)— returns the camelCased field names of the givenObjectTypes (fromObjectType._meta.fields).DenyAllRegistry— a fail-closedfrozensetwhose membership test is alwaysTrue. Return it fromget_protected_fieldswhen your schema/registry can't be built, so a broken schema fails closed (every field requires auth) instead of silently exposing everything:
from django_graphex.schema import DenyAllRegistry
try:
PROTECTED = collect_field_names(PrivateQueries, PrivateMutations)
except Exception:
PROTECTED = DenyAllRegistry() # broken schema -> everything is private
HTTP view hardening¶
Batch request size limit¶
BaseGraphQLView (and its subclasses) enforce a per-request operation limit when
batch=True is set. Requests that exceed MAX_BATCH_SIZE are rejected with
HTTP 400 before any operation is executed.
See MAX_BATCH_SIZE in the settings reference.
Endpoint access group (API_ACCESS_GROUP)¶
AuthenticatedGraphQLView can lock the authenticated endpoint to members of a
single Django auth Group. Set API_ACCESS_GROUP to the
group name; non-members are rejected with a generic HTTP 403 before any GraphQL
parsing or execution, and the message never reveals the group requirement. An
active superuser always bypasses the gate (hardcoded), and a missing/anonymous
user is denied (fail-closed). "" (default) disables it, and the public
GraphQLView is not affected. See
Views → Restricting the endpoint to a group.
Permission-scoped schema (PERMISSION_SCOPED_SCHEMA)¶
Generated mutation FK/M2M ID links have a separate destination row-scope
check when the destination public type overrides get_queryset. Hidden and
nonexistent IDs return the same structured invalid-pk error before the parent
write; child write permissions do not run for a link. Automatic FK/reverse-FK
and OneToOne output reads, automatic declared/reverse M2M output lists and
counts, plus forward-FK/forward-OneToOne/reverse-FK/M2M filters, have separate
bounded destination-scope rules. Other joins and explicit resolvers are not
globally scoped by this ID-link check. See
mutation ID-link migration.
Adopting an existing nullable reverse-FK orphan by ID is a child write, not an FK/M2M ID link. It requires an update-serving child host and passes that host's existing scope and permission checks; see reverse-FK orphan adoption.
With PERMISSION_SCOPED_SCHEMA enabled,
AuthenticatedGraphQLView serves each authenticated request a schema pruned to
the caller's permissions — a field the caller lacks perms for is absent from
their schema, not merely blocked at resolve time. This closes the existence
leak that resolver-level authorization leaves open: a blocked resolver still
reveals that the field exists (via an authorization error on a queryable field),
whereas a pruned field reads as a native Cannot query field — a not-found
indistinguishable from a typo.
Security model:
- No existence leak — pruned fields are gone from validation, so denials are not-found errors, never authorization errors. Nothing in any error path reveals a hidden field ever existed.
- No cross-permission cache leak — the pruned schema, the in-process schema
cache, and the HTTP response cache are all keyed by the caller's
permission signature. Normally that is
perms ∩ schema label-set; schemas with a native flat GFK fallback use all grants because an unregistered target model is absent from the static labels. Two callers with different relevant perms never share a pruned schema or a cached response body for the same query. Flat GFK targets are also checked against the actual target row scope at execution. - Empty root ⇒ generic 403 — a caller whose entire
Queryroot is pruned away gets the endpoint's generic403, byte-identical to thepermission_classes/API_ACCESS_GROUPdenials — an empty schema is indistinguishable from any other refusal. - Superuser & public-view invariants — an active superuser always gets the
full schema (no signature computed); the public
GraphQLViewis never pruned. - Revoke-safe — the signature is recomputed from live permissions each request (never keyed by user id, never persisted to an external cache), so a revoked grant takes effect on the caller's next request.
- Untagged = public — a field with no
gdx_required_permslabel survives every signature, so an unlabeled schema is unaffected (byte-identical to today).
Requires a labeled DjangoGraphQLSchema.
Default False is fully inert. See
Views → Permission-scoped schema
and, for subscriptions,
Subscriptions → Per-connection schema.
End-to-end guide
For a worked, role-by-role walkthrough of the whole permission stack —
pruned SDL per user, the exact denial responses, DjangoModelPermissions,
API_ACCESS_GROUP and per-action subscription pruning — see the
Permission-scoped schema guide.
GraphiQL CDN Subresource Integrity¶
When graphiql=True, the built-in CDN page (GRAPHIQL_HTML) loads React and
GraphiQL from unpkg.com with:
- Pinned patch versions — URLs use exact
@X.Y.Zversions, not floating major tags. - Subresource Integrity (SRI) — every
<script>and<link>tag carries anintegrity="sha384-…"attribute that the browser verifies before evaluating the asset. A compromised CDN or unexpected version bump cannot inject malicious JavaScript without the browser rejecting the asset.
The pinned versions and SRI hashes are documented inline in views.py.
When upgrading to a newer React or GraphiQL release, recompute the hashes:
curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
# Prepend "sha384-" to the output.
To serve assets from your own infrastructure (offline / strict-CSP setups), point the view at a custom template:
AST-based introspection detection (CLEAN_RESPONSE)¶
When CLEAN_RESPONSE=True is set, GraphQLView passes ordinary response data
through clean_dict. It recursively removes null, empty strings, and empty
lists or objects (including containers emptied by cleaning), while retaining
numeric zero and false in fields and arrays. Array elements that remain keep
their original order. The default is False, which leaves the full payload
unchanged. For example, an ordinary response is cleaned as follows:
becomes:
Introspection responses (__schema / __type queries) are exempt;
cleaning them would corrupt fields that legitimately return null or [].
The check is AST-based: a response is treated as introspection when the
selected operation has at least one root field and all its reachable root
fields are __schema or __type. Root inline fragments and named fragment
spreads count too; aliases do not change the underlying field name:
query TypeDetails {
...TypeRoot
}
fragment TypeRoot on Query {
selectedType: __type(name: "Query") {
description
interfaces { name }
}
}
With CLEAN_RESPONSE=True, this response keeps description: null and
interfaces: [] rather than removing them. A normal root field mixed into a
direct, inline, or named fragment makes the response eligible for ordinary
cleaning; __typename is not an introspection entry point. Missing or cyclic
root spreads do not gain an exemption. For multi-operation documents,
operationName chooses the operation; unrelated operations and fragments do
not change its cleaning policy. CLEAN_RESPONSE=False remains unchanged.
Query depth & cost limits¶
Two validation rules protect your API from over-nested or over-wide queries
(Meta.max_depth, Meta.complexity, MAX_QUERY_DEPTH, MAX_QUERY_COST,
EXPOSE_QUERY_COST). They are documented on their own page —
Query depth & cost limits.
Error codes¶
Errors the library raises during execution carry a machine-readable
extensions.code so clients can branch on the failure type:
| Code | Raised by | HTTP status_code |
|---|---|---|
UNAUTHENTICATED |
AuthenticatedFieldsMiddleware (private field, no user) |
401 |
PERMISSION_DENIED |
permission classes on DjangoModelType |
403 |
INTROSPECTION_DISABLED |
DisableIntrospectionMiddleware |
403 |
QUERY_TOO_DEEP |
DepthLimitValidationRule |
— (validation) |
QUERY_TOO_COMPLEX |
CostLimitValidationRule |
— (validation) |
{ "errors": [{ "message": "Query exceeds the maximum nesting depth of 2 for 'CategoryGenericType'.",
"extensions": { "code": "QUERY_TOO_DEEP" } }] }
Two error shapes
These are GraphQL execution errors (top-level errors with
extensions.code). Mutation business errors are different: a
DjangoModelType/DjangoModelMutation returns ok: false and a
structured errors list of { field, messages } (e.g.
{ "field": "id", "messages": ["Author with id 9 does not exist."] }) — the
operation itself succeeds, the payload reports the validation outcome.