| title | GraphQL Security | ||||
|---|---|---|---|---|---|
| slug | graphql-security | ||||
| category | appsec | ||||
| depth | 3 | ||||
| audit_level |
|
||||
| last_reviewed | 2026-04-21 | ||||
| sources |
|
||||
| triggers_strong |
|
||||
| triggers_weak |
|
||||
| related |
|
Last reviewed: 2026-04-19 | Next review: 2026-10-19 | Priority: Essential | Audit Level: 2-3 | Automation: Partial (depth, complexity, persisted query policy, and gateway limits partly automatable; resolver authorization, schema review, and business-flow abuse analysis manual)
Use this file when a GraphQL surface needs a security review that goes beyond "disable introspection" and covers authorization, cost, batching, uploads, subscriptions, and resolver fan-out.
GraphQL is not "less secure" than REST, but it shifts the control points. The real risk is not the presence of a /graphql endpoint; it is that a client can choose the shape, depth, cost, and sometimes the exact properties of the request. Controls must therefore be designed around four questions:
- Who can call what?
AuthN/AuthZ at the object, field, and action level. - How much work does the request trigger?
Depth limit, complexity budget, alias/batching caps, backend fan-out. - What does the API reveal about itself?
Introspection, field suggestions, error messages, global IDs. - How does the API deviate from the classic request/response model?
Multipart upload, persisted queries, WebSocket subscriptions, N+1, telemetry.
- Strong AuthN at the transport level.
- AuthZ at the object level and at the property level.
A resolver that "returns the object if the user is logged in" is not sufficient. - Depth and complexity/cost limits.
- Volume limits: document size, number of aliases, number of operations per batch, response size.
- Introspection disabled in production for unprivileged clients.
- Per-operation telemetry: name, depth, cost, alias count, backend calls, latency, errors, principal, tenant.
- Persisted queries allowlisted for public surfaces when possible.
- Errors masked on the client side, detailed only in internal logs.
Depth limits trees of the form:
query {
me {
manager {
manager {
manager {
reports {
manager {
reports {
id
}
}
}
}
}
}
}
}- Public API: max depth 6 to 8 to start.
- Internal API / BFF: 8 to 12 if telemetry shows real needs.
- Exclude trivial fields from cost, not from depth, unless justified.
- Apply the rule before execution.
- Rely solely on an HTTP rate limit counted "per request".
- Allow high depth without a complexity budget.
- Exempt an entire mobile client without per-operation metrics.
Depth alone does not catch "flat" but very expensive queries:
query SearchEverything {
users(first: 1000) { edges { node { id email roles teams { id name } } } }
invoices(first: 1000) { edges { node { id amount customer { id tier } } } }
projects(first: 1000) { edges { node { id repo ciRuns(first: 100) { id status } } } }
}Define a cost per field:
- simple scalar:
1 - 1->1 relation:
2 - paginated connection:
base + multiplier * first - fields hitting an expensive backend: higher cost
- "admin", "search", "export" fields: higher cost + server cap
Simple example:
total_cost =
sum(field costs)
+ sum(pagination multipliers)
+ surcharge on backend fan-out fields
- Set a global budget per operation, for example
300or500. - Add a per-role budget if some clients are internal.
- Log the computed cost for each operation.
- Reject requests without an operation name in production if you depend on fine-grained metrics.
- Allow
first: 1000everywhere because "the client needs it". - Count only GraphQL nodes without looking at real SQL/HTTP fan-out.
- Reserve cost analysis to the gateway without feedback on expensive resolvers.
Two mechanisms must be distinguished:
- Primary purpose: performance.
- The client sends a SHA-256 hash.
- If the server does not know the hash, the client then resends the full query.
- Very useful for reducing request size and improving CDN caching.
- Primary purpose: security + stability.
- Only known, versioned, and manifest-published queries are accepted.
- Much stronger against opportunistic discovery, arbitrary batching, and handcrafted queries.
- Public / mobile / consumer web surface: prefer the allowlist.
- Internal surface: APQ is possible, but keep depth/complexity/rate limits.
- Do not sell APQ as a security control in itself.
- "We have APQ, so we can leave introspection and batch requests open".
- "We allowlist queries, so we no longer need field-level AuthZ".
Introspection is not a vulnerability in itself, but it:
- speeds up mapping by an attacker,
- reveals types, relations, admin mutations, and field names,
- increases the effectiveness of fuzzing attacks and BOLA/BOPLA.
- Disable in production for unprivileged clients.
- If you must keep it:
- restrict to internal admins/devs,
- log all calls,
- require an approved client ID or trusted origin.
- Leaving GraphiQL / Playground in production "because debugging is needed".
- Confusing IDE disabling with introspection disabling.
If your server accepts an array of operations in a single HTTP request, an attacker can:
- bypass a rate limit counted "per request",
- mix small enumeration queries,
- amplify CPU/memory impact.
- Disable batching if you do not need it.
- Otherwise:
- cap the number of operations per batch,
- apply cost across the entire batch,
- count the batch as N operations for rate limiting.
Example:
query {
a1: search(q: "x") { id }
a2: search(q: "x") { id }
a3: search(q: "x") { id }
# ...
a500: search(q: "x") { id }
}A naive rate limit sees "one request". The backend potentially sees 500 executions.
- Cap the number of aliases.
- Include
alias_countin the cost. - Reject documents with too many tokens or repeated fields.
Many servers respond:
Cannot query field
usrs. Did you meanusers?
This is very useful for a developer, and equally useful for an attacker in the mapping phase.
- Disable suggestions in production if the framework allows it.
- Otherwise, hide the details on the client side via an error presenter / formatter.
- Never return the internal stack or admin type names.
Relay and global IDs do not eliminate the IDOR/BOLA risk.
A global ID is just another identifier. Even if base64-encoded, it often remains:
- predictable,
- replayable,
- or easily collectable from the UI.
- Control access in the object resolver, not only in the parent.
- Verify
tenant_id, ownership, business relationship, object state. - Enforce pagination caps on Relay connections.
- Log
node(id:)calls and server-side decoded IDs.
- "The global ID is not sequential, so it is not IDOR."
- Access control at the list level, but not at the
node(id:)level.
N+1 is not just an accidental performance problem. It is also an abuse surface:
- a client can force enormous fan-out,
- cause SQL/HTTP saturation,
- exploit fields that appear innocent but are very expensive.
- Use dataloaders / batch resolvers.
- Instrument
resolver_count,db.query.count,downstream_call_count. - Add a specific cost to fields that fan out.
- Set a server-side ceiling on
first,last,limit.
In production, the client must not receive:
- stack trace,
- table name,
- SQL message,
- upstream details,
- internal type/resolver names.
- Respond with a generic message.
- Set a stable code in
extensions.codeif needed. - Keep full detail in correlated logs/traces.
- Reusing
err.Error()as-is from the ORM or upstream. - Making client behavior depend on an unstable error message.
The best model for files remains:
- mutation to request a signed URL,
- direct upload to storage,
- confirmation mutation with server-side metadata.
- more subtle CSRF/browser surface,
- harder backpressure,
- riskier parsing,
- mixing of API logic and file transport,
- validation and scanning often forgotten.
- limit size, MIME type, number of files,
- antivirus / malware scan,
- rename on the server side,
- forbid client-supplied names/paths,
- isolate storage,
- require appropriate CSRF protection,
- never trust
Content-Typealone.
Subscriptions break the illusion that "one verified token per HTTP request is sufficient".
- Auth at the time of the WebSocket upgrade or
connection_init. - Authorization context injected into the subscription.
- Revalidation if the token is expired or a refresh is needed.
- Verification on each event if the right depends on the object/tenant.
CheckOrigin/ strict origin allowlist.- Quotas for concurrent connections and subscriptions per socket.
- Checking only the presence of a token, not its validity.
- Performing AuthZ at subscription time, then pushing events without re-filtering.
- Accepting any WebSocket origin.
import { ApolloServer } from "@apollo/server";
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
schema,
introspection: false, // prod
hideSchemaDetailsFromClientErrors: true,
csrfPrevention: true,
validationRules: [
depthLimit(8),
],
formatError(formattedError) {
// Keep detail in logs; return a stable message to the client.
return {
message: "Request rejected",
extensions: {
code: formattedError.extensions?.code ?? "GRAPHQL_ERROR",
},
};
},
});import { ApolloServer } from "@apollo/server";
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
import depthLimit from "graphql-depth-limit";
const complexityRule = createComplexityRule({
maximumComplexity: 400,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity: number) => {
console.log("graphql_complexity", { complexity });
},
});
const server = new ApolloServer({
schema,
introspection: false,
hideSchemaDetailsFromClientErrors: true,
csrfPrevention: true,
validationRules: [depthLimit(8), complexityRule],
});Tip: for expensive fields, declare a cost via a schema extension or a server-side registry, then increase the cost for paginated connections.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
const allowlist = new Set([
// SHA-256 of normalized and published operations
"7b3f6e4c2f5d6a4f6b1d54b9f3f5a4e9c7f3b1d8a9c4e2f6d1b3c5a7e9f1d2c3",
]);
app.use("/graphql", (req, res, next) => {
const hash = req.body?.extensions?.persistedQuery?.sha256Hash;
if (!hash || !allowlist.has(hash)) {
return res.status(403).json({
errors: [{ message: "Unknown persisted query" }],
});
}
next();
});import express from "express";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express5";
import { graphqlUploadExpress, GraphQLUpload } from "graphql-upload";
const app = express();
app.use(graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 2,
}));
const server = new ApolloServer({
typeDefs,
resolvers: {
Upload: GraphQLUpload,
Mutation: {
async uploadAvatar(_: unknown, { file }: any, ctx: MyContext) {
if (!ctx.principal) throw new Error("Unauthorized");
const upload = await file;
if (!["image/png", "image/jpeg"].includes(upload.mimetype)) {
throw new Error("Unsupported media type");
}
// Do not use the client-supplied name as the final path.
const safeObjectKey = `avatars/${ctx.principal.userId}/${crypto.randomUUID()}`;
// stream -> AV scan -> object storage
return { ok: true, objectKey: safeObjectKey };
},
},
},
csrfPrevention: true,
});import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/use/ws";
const wsServer = new WebSocketServer({
server: httpServer,
path: "/graphql",
});
useServer(
{
schema,
onConnect: async (ctx) => {
const auth = String(ctx.connectionParams?.authorization ?? "");
if (!auth) throw new Error("Unauthorized");
},
context: async (ctx) => {
const auth = String(ctx.connectionParams?.authorization ?? "");
const principal = await verifyBearer(auth);
if (!principal) throw new Error("Unauthorized");
return { principal };
},
},
wsServer,
);To add in production: origin allowlist, per-socket quotas, token revalidation before pushing sensitive events.
The examples below primarily show the structure of controls. Adapt the integration (ASGI, FastAPI, Django, etc.) to your stack.
import strawberry
from strawberry.schema.config import StrawberryConfig
from strawberry.extensions import AddValidationRules
from graphql.validation import NoSchemaIntrospectionCustomRule
@strawberry.type
class Query:
@strawberry.field
def health(self) -> str:
return "ok"
schema = strawberry.Schema(
query=Query,
config=StrawberryConfig(
disable_field_suggestions=True,
relay_max_results=50,
),
extensions=[
AddValidationRules([NoSchemaIntrospectionCustomRule]),
# Add your depth / alias / token cap rules here
],
)from fastapi import FastAPI, Request, WebSocket
from strawberry.fastapi import GraphQLRouter
async def get_context(request: Request | WebSocket):
auth = request.headers.get("authorization", "")
principal = await verify_bearer(auth)
if not principal:
raise Exception("Unauthorized")
return {"principal": principal}
graphql_app = GraphQLRouter(
schema,
context_getter=get_context,
graphql_ide=None, # no IDE in production
multipart_uploads_enabled=False, # keep False by default
)
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")@strawberry.type
class Mutation:
@strawberry.mutation
async def update_user_role(self, info, user_id: strawberry.ID, role: str) -> bool:
principal = info.context["principal"]
if not principal.is_admin:
raise Exception("Forbidden")
if role not in {"viewer", "editor", "admin"}:
raise Exception("Invalid role")
await user_service.update_role(user_id=user_id, role=role, actor_id=principal.user_id)
return True# Recommended model
@strawberry.type
class Mutation:
@strawberry.mutation
async def create_upload_url(self, info, filename: str, content_type: str) -> str:
principal = info.context["principal"]
if not principal:
raise Exception("Unauthorized")
if content_type not in {"image/png", "image/jpeg"}:
raise Exception("Unsupported media type")
return await object_store.create_signed_upload_url(
owner_id=principal.user_id,
content_type=content_type,
ttl_seconds=300,
)graphql_ide=Nonein production.multipart_uploads_enabled=Falseby default: keep this choice as long as possible.disable_field_suggestions=Truereduces information leakage of the "Did you mean..." type.relay_max_resultsmust be tight to prevent overly broad connections.- Add extensions/rules for:
- depth,
- number of aliases,
- number of tokens,
- document size.
package main
import (
"context"
"errors"
"net/http"
"os"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/gorilla/websocket"
"github.com/vektah/gqlparser/v2/gqlerror"
)
type principalKey struct{}
func main() {
srv := handler.New(executableSchema)
srv.AddTransport(transport.Options{})
srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})
srv.Use(extension.FixedComplexityLimit(400))
if os.Getenv("ENV") != "production" {
srv.Use(extension.Introspection{})
}
srv.SetErrorPresenter(func(ctx context.Context, e error) *gqlerror.Error {
err := graphql.DefaultErrorPresenter(ctx, e)
err.Message = "Request rejected"
return err
})
srv.SetRecoverFunc(func(ctx context.Context, err interface{}) error {
return gqlerror.Errorf("Internal server error")
})
http.Handle("/graphql", srv)
http.ListenAndServe(":8080", nil)
}srv.AddTransport(transport.Websocket{
KeepAlivePingInterval: 15 * time.Second,
Upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://app.example.com"
},
},
InitFunc: func(ctx context.Context, initPayload transport.InitPayload) (context.Context, error) {
token := initPayload.Authorization()
if token == "" {
return nil, errors.New("missing authorization")
}
principal, err := verifyBearer(token)
if err != nil {
return nil, errors.New("unauthorized")
}
return context.WithValue(ctx, principalKey{}, principal), nil
},
})# gqlgen.yml
uploadMaxSize: 10485760 # 10 MiB
uploadMaxMemory: 1048576 # 1 MiB in memory before spill to diskfunc (r *queryResolver) Invoice(ctx context.Context, id string) (*model.Invoice, error) {
principal, _ := ctx.Value(principalKey{}).(*Principal)
if principal == nil {
return nil, errors.New("unauthorized")
}
invoice, err := r.invoiceStore.ByID(ctx, id)
if err != nil {
return nil, errors.New("not found")
}
if invoice.TenantID != principal.TenantID && !principal.IsAdmin {
return nil, errors.New("forbidden")
}
return invoice, nil
}- Rate limit by:
- IP,
- user,
- client ID,
- operation name,
- cumulative cost.
- Rejection of anonymous or oversized documents.
- Maximum body size.
- Separate quotas for mutations, subscriptions, and expensive queries.
- Logging of:
operation_name,principal,tenant,depth,complexity,alias_count,resolver_count,db_query_count,persisted_query_hash,status_code,error_code.
| Control | What to look for | Red flag |
|---|---|---|
| Depth limit | Rule active in production | only in staging |
| Complexity budget | max cost + logs | no per-operation telemetry |
| Persisted queries | versioned manifest / allowlist | APQ alone sold as security |
| Introspection | disabled or restricted | GraphiQL/Playground exposed in production |
| Alias / batching | explicit caps | free and uncounted batch |
| Object AuthZ | check in each sensitive resolver | control only at the parent |
| Property AuthZ | whitelist/guards on sensitive fields | roles, ssn, apiKeys returned to standard client |
| Error masking | presenter/formatter | err.Error() returned as-is |
| Upload | signed URL or strict bounds | multipart without AV, without CSRF, without caps |
| Subscriptions | Auth at handshake + revalidation | token read once then forgotten |
| Relay/node IDs | ownership/tenant verification | trusting base64 opacity |
| N+1 | dataloaders + metrics | unmeasured backend fan-out |
| Topic | Primary CWE | Notes |
|---|---|---|
| IDOR / BOLA | CWE-639 | Authorization Bypass Through User-Controlled Key |
| BOPLA / field overexposure | CWE-200 / CWE-863 | data exposure + insufficient authz |
| Mass assignment | CWE-915 | uncontrolled dynamic property modification |
JWT alg=none / claims confusion |
CWE-345 / CWE-347 | authenticity / signature poorly verified |
| SSRF | CWE-918 | Server-Side Request Forgery |
| Alias DoS / batching / N+1 abuse | CWE-400 | Uncontrolled Resource Consumption |
| Introspection / suggestions / verbose errors | CWE-200 / CWE-209 | information disclosure |
| Poorly bounded multipart upload | CWE-434 | dangerous type upload; complement with CSRF controls |
| Insufficient subscription auth | CWE-306 / CWE-862 | critical function insufficiently authenticated/authorized |
| GraphQL risk | OWASP API Top 10 |
|---|---|
IDOR via node(id:), neighboring REST/GraphQL objects |
API1: Broken Object Level Authorization |
| Weak JWT, weak subscription auth | API2: Broken Authentication |
| Sensitive field overfetch, mass assignment | API3: Broken Object Property Level Authorization |
| Depth/complexity/batching/alias/N+1 abuse | API4: Unrestricted Resource Consumption |
| Introspection, field suggestions, GraphiQL in production, detailed errors | API8: Security Misconfiguration |
| SSRF via fetchers / webhooks / URL import | API7: Server-Side Request Forgery |
| Login flows exposed to credential stuffing | API6: Unrestricted Access to Sensitive Business Flows |
- AuthN/AuthZ everywhere an object becomes addressable again.
- What is expensive must be measured, then bounded.
- What helps the developer in production also helps the attacker.
- A hash (APQ) is not an allowlist.
- Base64 is not an authorization.
- A subscription is not a long HTTP request: it is a live channel.
GraphQL touches GDPR primarily through data minimisation and security of processing. The controls below have a direct Article mapping.
| Control | GDPR Article | Rationale |
|---|---|---|
| Field-level AuthZ (object + property) | Art. 5(1)(c) — Minimisation; Art. 25 — Privacy by default | Users and roles should only receive the personal data fields they legitimately need. A resolver returning ssn, salary, mfaSecret, or recoveryCodes to a low-privilege principal violates minimisation by default. |
| Disabled introspection in production | Art. 25 — Privacy by default | Introspection exposes the full data model including field names that hint at personal data categories. Disabling it by default limits unnecessary data disclosure to unauthenticated or low-privilege clients. |
IDOR / BOLA controls on node(id:) and REST-equivalent resolvers |
Art. 32 — Security of processing | Unauthorized access to personal data objects via predictable IDs constitutes a personal data breach under Art. 4(12) and may trigger Art. 33 notification. Resolver-level ownership checks are a required technical measure. |
| Error masking | Art. 32 — Security of processing; Art. 5(1)(f) — Confidentiality | Stack traces, table names, and field names in error responses can reveal personal data schema. Error presenters are a confidentiality control. |
| Upload controls (MIME, size, AV scan, server-side rename) | Art. 32 — Security of processing | Malicious uploads can compromise the system storing personal data. Upload hardening reduces the attack surface on data stores. |
| Persisted query allowlist | Art. 25 — Privacy by default | An allowlist enforces that only known, reviewed queries — assessed for necessity and proportionality — can execute. Ad-hoc queries from unknown clients bypass minimisation intent. |
| Complexity / depth / batching limits | Art. 32 — Security of processing | Unconstrained queries can trigger mass extraction of personal data (bulk export via deep traversal or alias amplification). Resource limits are also data exfiltration controls. |
If the GraphQL API exposes personal data (user profiles, health data, financial data, etc.), a DPIA screening should check:
- Is field-level AuthZ enforced at the resolver level — not only at the parent query?
- Can any query path return more personal data than the use case requires?
- Is introspection disabled or restricted to authenticated internal principals?
- Are IDOR/BOLA controls in place for every globally addressable object carrying personal data?
- Are logs structured to detect abnormal access patterns (volume, field selection, principal/tenant mismatch)?
These five questions map directly to Art. 35(7)(d) — technical and organisational measures adopted to address identified risks.