graphene-shield
Stop rewriting the same GraphQL plumbing in every Django project. graphene-shield adds the layer Graphene-Django leaves to you:
- one response shape for every mutation;
- filterable, sortable list fields with three pagination styles;
- upload validation that looks at file content, not file names;
- login checks that work with JWT, sessions or your own scheme;
- error responses that never leak internals.
Every part has a safe default.
GraphQL request
│
▼
┌───────────────────────────────┐
│ GraphQLView (depth limit) │
└───────────────┬───────────────┘
│
┌─────────────────┴─────────────────┐
▼ ▼
BaseMutation ModelListField
login_required → sanitize input filters → search → sort → paginate
→ validate_input (+ uploads) (offset │ keyset │ page, lazy COUNT)
→ perform_mutation (atomic) │
│ │
└─────────────────┬─────────────────┘
▼
data, or one JSON error body with a code
Contents
- Installation
- Quick start
- Why use it?
- Mutations
- List queries
- Pagination
- File validation
- Authentication
- Errors and query limits
- Configuration
- Limitations
More detail: configuration reference · API reference · how it works
Installation
pip install graphene-shield # core
pip install "graphene-shield[all]" # every optional extra
| Extra | Adds |
|---|---|
optimizer |
graphene-django-optimizer: list querysets are optimised automatically |
upload |
graphene-file-upload: FileUploadGraphQLView for multipart uploads |
files |
pypdf (thorough PDF check, recommended) and python-magic (legacy Office detection; needs the system libmagic) |
jwt |
PyJWT: the JWT authenticator |
Tested on Python 3.10–3.13 with Django 4.2, 5.2 and 6.1 (Django 6 needs Python 3.12+).
Quick start
# schema.py
import graphene
from graphene import relay
from graphene_shield.connections import CountableDjangoObjectType
from graphene_shield.mutations import BaseMutation
from graphene_shield.queries import ModelListField
from .models import Book
class BookType(CountableDjangoObjectType):
class Meta:
model = Book
interfaces = (relay.Node,)
fields = ("id", "title", "author")
class CreateBook(BaseMutation):
book = graphene.Field(BookType)
class Arguments:
title = graphene.String(required=True)
@classmethod
def perform_mutation(cls, root, info, title):
book = Book.objects.create(title=title)
return cls(book=book, response=[cls.success("Book created.", "BOOK_CREATED")])
class Query(graphene.ObjectType):
book_lists = ModelListField(BookType, search_fields=["title", "author"])
class Mutation(graphene.ObjectType):
create_book = CreateBook.Field()
schema = graphene.Schema(query=Query, mutation=Mutation)
# urls.py
from django.urls import path
from graphene_shield.views import GraphQLView
from .schema import schema
urlpatterns = [path("graphql/", GraphQLView.as_view(schema=schema))]
mutation { createBook(title: "Dune") { book { title } response { message messageCode status } } }
{ bookLists(first: 10, search: "dune", sortBy: {field: CREATED_AT, direction: DESC}) {
totalCount edges { node { id title } } } }
Why use it?
| Problem | Without the package | With it |
|---|---|---|
| Inconsistent errors | Each mutation reports errors its own way | One response: [{message, messageCode, status, field}] everywhere |
| Leaking internals | return str(e) sends SQL errors to clients |
Errors are logged; clients get a generic message and a code |
| Half-finished writes | A crash after create() leaves partial data |
Each mutation runs in a transaction |
| Copy-pasted list queries | Filters, search, sort and pagination rewritten per model | One ModelListField(...) |
| Unbounded queries | first: 1000000 or deep nesting overloads the server |
Page-size cap and query depth limit |
| Unsafe uploads | Trusting the file name | Content-based checks; dangerous PDFs and macro files rejected |
| Fragile JWT checks | Refresh tokens accepted, one generic "invalid token" | A specific code per failure; token type and revocation checked |
| Login checked too late | Validation leaks "email already registered" | login_required runs before validation |
| Silently ignored filters | A filter on a missing field returns all rows | A BAD_REQUEST error |
Mutations
from graphene_shield.messages import ErrorMessage
from graphene_shield.mutations import BaseMutation
TITLE_REQUIRED = ErrorMessage("Title is required.", "TITLE_REQUIRED")
class CreateTask(BaseMutation):
login_required = True # authenticate before anything else runs
task = graphene.Field(TaskType)
class Arguments:
title = graphene.String()
attachment = Upload() # graphene_file_upload.scalars.Upload
@classmethod
def validate_input(cls, info, data):
errors = []
cls._validate_required_fields(data, {"title": TITLE_REQUIRED}, errors)
if data.get("attachment"):
cls._validate_file(data["attachment"], "attachment", errors, allowed_types={"PDF", "PNG"})
return errors
@classmethod
def perform_mutation(cls, root, info, title, attachment=None):
task = Task.objects.create(title=title)
return cls(task=task, response=[cls.success("Task created.", "TASK_CREATED")])
A failed validation returns
[{"message": "Title is required.", "messageCode": "TITLE_REQUIRED", "status": false, "field": "title"}].
- Input clean-up. String inputs are stripped first. Password, secret and token values are never touched.
- Transactions.
perform_mutationruns in a transaction, so a failure rolls back its writes. - Unexpected exceptions are logged, and the client gets
UNEXPECTED_ERROR. SetEXPOSE_EXCEPTION_MESSAGES = Truein development to see the real message.
List queries
from graphene_shield.authentication import login_required
from graphene_shield.filters import BaseFilterInput
from graphene_shield.queries import ModelListField
from graphene_shield.sorting import make_sort_field_enum, make_sorting_input
class TaskFilterInput(BaseFilterInput): # adds id / isActive / createdAt / updatedAt
status = graphene.String()
@classmethod
def filter_queryset(cls, queryset, filters):
if status := filters.get("status"):
queryset = queryset.filter(status=status)
return super().filter_queryset(queryset, filters)
TaskSort = make_sort_field_enum("TaskSortField", {"DUE_DATE": ["due_date", "pk"]})
class Query(graphene.ObjectType):
task_lists = ModelListField(
TaskType,
filter_input=TaskFilterInput,
search_fields=["title", "assignee__email"],
sort_input=make_sorting_input("TaskSortingInput", TaskSort),
max_limit=100,
)
@login_required
def resolve_task_lists(root, info, **kwargs):
# Only scope what this user may see; filters, search, sort and paging are applied for you.
return Task.objects.filter(school__members=info.context.user)
- Models without audit columns. For models without
is_active/created_at/updated_at, useIdFilterInput, or rename columns withcommon_field_map = {"is_active": "enabled"}. - Unsupported filters or sorts return
BAD_REQUESTorINVALID_SORT_FIELD; rows are never silently left unfiltered.
Pagination
Pick a style for every list with the PAGINATION setting, or per field with pagination="...":
"offset" (default) |
"keyset" |
"page" |
|
|---|---|---|---|
| Client sends | first, after, offset |
first, after |
page, pageSize |
| Client gets | Relay connection | Relay connection | items + pageInfo |
| Deep pages | Slower the further you go | Fast | Slower the further you go |
| Rows added or removed while paging | Can skip or repeat rows | Never | Can skip or repeat rows |
| Best for | Admin tables | Infinite scroll, big tables | "Page 3 of 12" |
{ auditLists(page: 3, pageSize: 20) {
items { id }
pageInfo { page totalPages totalCount hasNextPage } } }
In every style, COUNT(*) only runs when the client asks for totalCount or totalPages.
File validation
from graphene_shield.files import validate_file
error = validate_file(upload, allowed_types={"PDF", "DOCX"}, max_file_size=5 * 1024 * 1024)
# None if valid, otherwise FILE_REQUIRED / FILE_TOO_LARGE / INVALID_FILE_TYPE / ACTIVE_CONTENT_DETECTED
- Detected from content. The type comes from the file's bytes, not its name. Supported: PDF, PNG, JPEG, GIF, ICO, TXT/CSV, DOCX, XLSX, PPTX, DOC, XLS and PPT.
- Always rejected:
- PDFs with JavaScript, auto-actions or embedded files (signed or not);
- macro-enabled Office files;
- HTML or SVG disguised as text;
- files that can't be inspected.
- Defaults: 10 MB, PDF only.
Authentication
login_required = True (mutations) and @login_required (queries) use the AUTHENTICATOR setting:
| Your project uses | AUTHENTICATOR |
|---|---|
| JWT (default) | "graphene_shield.auth.authenticate_request" |
| Django sessions | "graphene_shield.authentication.session_authenticate" |
| Anything else | Your own fn(request) -> user |
- Header formats. JWT accepts
Authorization: Bearer <token>orJWT <token>, in any case. Other prefixes, bare tokens and custom headers are one setting away. - What's checked: signature, expiry, algorithm, token type (so refresh tokens are rejected), revocation and inactive users. Each failure has its own error code.
- Your own rules.
USER_VALIDATOR = "myapp.auth.check_user"adds app rules, such as requiring a verified email. It runs once per request with every authenticator. - No auth configured means public. Without these checks, every list,
node(id:)and mutation is open to everyone, so scope private data in resolvers andget_queryset.
Errors and query limits
# urls.py
from graphene_shield.views import FileUploadGraphQLView # or GraphQLView
urlpatterns = [path("graphql/", FileUploadGraphQLView.as_view(graphiql=settings.DEBUG))]
Successful responses pass through unchanged. Errors come back as one body with HTTP 400:
{"message": "Token has expired.", "message_code": "TOKEN_EXPIRED", "status": false,
"field": null, "data": [], "errors": ["Token has expired."]}
Queries nested deeper than MAX_QUERY_DEPTH (15) are rejected before they run.
Configuration
Every setting is optional. A misspelled key raises an error at startup.
# settings.py
GRAPHENE_SHIELD = {
"PAGINATION": "keyset", # "offset" | "keyset" | "page"
"CONNECTION_MAX_LIMIT": 100, # largest page a client may request
"EXPOSE_EXCEPTION_MESSAGES": DEBUG, # real error messages in development only
"AUTHENTICATOR": "graphene_shield.authentication.session_authenticate",
"USER_VALIDATOR": "accounts.auth.require_verified_email",
}
| Setting | Default | Purpose |
|---|---|---|
PAGINATION |
"offset" |
Default pagination style |
CONNECTION_MAX_LIMIT |
1000 |
Largest first/last/pageSize |
DEFAULT_PAGE_SIZE |
20 |
Page size for "page" pagination |
MAX_QUERY_DEPTH |
15 |
Deepest allowed query (None disables) |
EXPOSE_EXCEPTION_MESSAGES |
False |
Send real exception messages to clients |
AUTHENTICATOR |
JWT | How login_required authenticates |
USER_VALIDATOR |
None |
Extra per-user login rules |
JWT_* |
See the reference | Signing key, algorithms, header prefixes, claims, revocation |
All settings, with seven ready-made setups (simplejwt, sessions, Auth0/Keycloak, mobile, admin dashboards…), are in the configuration reference.
Limitations
- Graphene-Django only. It isn't for Strawberry, Ariadne or REST APIs.
- N+1 queries. No prevention beyond the optional optimizer. Use
select_related/prefetch_related. - Error envelope. It returns one error body and drops partial
data. - Query limits. Only depth is limited; there are no alias, batch or cost limits.
- Keyset pagination can't jump to a page and needs non-null sort fields.
- Upload checks. Legacy Office macros and CSV formula injection aren't detected.
- Features not included: field-level permissions, async resolvers and subscriptions.
License
MIT. See LICENSE.
Metadata
Release files for graphene-shield 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| graphene_shield-0.1.1.tar.gz | 55.2 kB | With provenance | Details for graphene_shield-0.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| graphene_shield-0.1.1-py3-none-any.whl | Python 3 | none | any | Details for graphene_shield-0.1.1-py3-none-any.whl |
Total release size: 95.2 kB
Release files / graphene_shield-0.1.1.tar.gz
| Download URL | graphene_shield-0.1.1.tar.gz |
|---|---|
| Size | 55.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cd48b654530533d87bdbef4f0a7b5a397e200664b99a15c75c02b9453d1fb5a5
|
|
BLAKE2b-256 checksum How to use checksums |
1ec4f94b7657947cbe8f2ce436d9d5a3618cc06c2a58f6e5e57ea992111bcba4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logRelease files / graphene_shield-0.1.1-py3-none-any.whl
| Download URL | graphene_shield-0.1.1-py3-none-any.whl |
|---|---|
| Size | 40.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f28c514225b26400a8c5d28ec70e46db241d7ec5e5bde0b14c7ab37fe2a365cd
|
|
BLAKE2b-256 checksum How to use checksums |
1a70ed75ec64fdbe912202fecf149718e318f15aa4ed65160f88d0b0de8cc1e9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logSecurity for graphene-shield 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for graphene_shield-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.