GraphQL Security Audit
Audit GraphQL APIs for vulnerabilities specific to the query language and protocol: complexity attacks, introspection leaks, field-level auth bypass, mutation gaps.
When this skill applies
- Reviewing GraphQL schemas and resolvers
- Auditing depth / complexity / cost limits
- Reviewing field-level authorization
- Checking introspection exposure in production
- Auditing persisted queries setup
- Reviewing GraphQL subscriptions and WebSocket auth
Use other skills for: REST API patterns (saas-security-pack/saas-api-security), backend framework auth wiring (nodejs-express-security, nestjs-security, fastapi-security), IDOR patterns generally (saas-security-pack/saas-code-security-review).
Workflow
Follow ../_shared/audit-workflow.md. GraphQL-specific notes below.
Phase 1: Stack detection
# Find the GraphQL server library
grep -E '"(apollo-server|@apollo/server|graphql-yoga|mercurius|express-graphql|@nestjs/graphql|graphql-tools)":' package.json
# Schema files
find . \( -name '*.graphql' -o -name '*.gql' -o -name 'schema.ts' \) -not -path '*/node_modules/*' | head -10
# Resolver locations
grep -rln 'Query:\|Mutation:\|Subscription:\|resolvers\s*=' src/ | head -10
Phase 2: Inventory
# Endpoint configuration
grep -rnE 'graphqlPath|graphqlMiddleware|playground|introspection' src/
# Auth context setup
grep -rn 'context:\s*\(' src/ | head
# Subscriptions (often a separate auth flow)
grep -rn 'Subscription\|subscribe\|WebSocketServer\|graphql-ws' src/
# Federation (gateway / subgraph patterns)
grep -rn '@apollo/gateway\|buildSubgraphSchema' src/
Phase 3: Detection — the checks
Introspection in production
If introspection must stay on (internal tooling), gate by auth — only authenticated admins.
Query depth and complexity
GraphQL queries can recurse via cyclic schema references. Without limits, a single query can cost megabytes of work:
query Evil {
user(id: "1") {
friends { friends { friends { friends { ... } } } }
}
}
- GQL-DEPTH-1 Depth limit configured (typical: 10-15). Libraries:
graphql-depth-limit, built-in to graphql-yoga, configurable in Apollo.
- GQL-COMP-1 Complexity / cost analysis configured. Libraries:
graphql-query-complexity, GraphQL Armor.
- GQL-COMP-2 Per-field cost annotations applied to expensive fields (search, full-text, pagination over large sets).
- GQL-COMP-3 Alias attack mitigated: counting aliases toward complexity (
a: thing b: thing c: thing should count as 3, not 1).
- GQL-COMP-4 Directives like
@stream and @defer (newer specs) have their own cost accounting if enabled.
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10),
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});
GraphQL Armor combines all these defaults in one plugin and is the lowest-friction way to deploy them.
Field-level authorization
REST has one auth check per endpoint; GraphQL has one auth surface per field. The mistake is checking at the root query and trusting the rest.
GQL-AZ-1 Resolvers for fields exposing other users' data check authorization on the parent object, not just the root.
// BAD — root checks auth but `users` resolver doesn't
const resolvers = {
Query: {
currentUser: requireAuth((parent, args, ctx) => ctx.user),
},
User: {
// Wide-open: anyone who reaches a User can ask for any field
email: (parent) => parent.email,
paymentMethods: (parent) => db.paymentMethods.findMany({ where: { userId: parent.id } }),
},
};
An attacker who navigates to a User from elsewhere (e.g., via a comment's author field) gets their email and payment methods.
GQL-AZ-2 Use directives or middleware for declarative field authz:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
type User {
id: ID!
displayName: String!
email: String! @auth(requires: SELF_OR_ADMIN)
paymentMethods: [PaymentMethod!]! @auth(requires: SELF)
}
Implement the directive to check ctx.user against the parent object on every field.
GQL-AZ-3 Tools: GraphQL Shield (rule-based authz), Apollo Connectors with auth context, custom middleware. Don't roll your own per-resolver if you have more than ~20 resolvers — the surface is too large to maintain manually.
Mutations
- GQL-MUT-1 Every mutation checks auth + authz. Sensitive mutations (delete account, change billing, admin actions) require additional verification (MFA, re-auth).
- GQL-MUT-2 Mutations don't trust input IDs — derive
userId / tenantId from ctx, not from arguments.
- GQL-MUT-3 Mutations idempotent or rate-limited where appropriate.
- GQL-MUT-4 Input validation via schema or in resolver (use zod / yup / ajv at the resolver boundary; GraphQL types don't validate ranges or formats).
Persisted queries
Persisted queries dramatically reduce attack surface: clients send a query hash, server looks up the actual query. Attackers can't send arbitrary queries.
- GQL-PQ-1 Persisted queries enabled for first-party clients (web, mobile).
- GQL-PQ-2 Public/external clients (third-party developers, partners) — either allow arbitrary queries with strict depth/complexity, or define a partner API with persisted queries.
- GQL-PQ-3 Persisted query registry write access restricted to CI/CD pipeline; not writeable from production.
- GQL-PQ-4 Apollo Persisted Query Manifest workflow or equivalent in use.
Batching abuse
# Single request, 1000 mutations
mutation BatchEvil {
m1: addCredit(userId: "self", amount: 1000)
m2: addCredit(userId: "self", amount: 1000)
m3: addCredit(userId: "self", amount: 1000)
# ...
}
- GQL-BAT-1 Limit alias count and operation count per request.
- GQL-BAT-2 Rate limit mutations server-side per user/tenant — within a single request and across requests.
- GQL-BAT-3 Idempotency keys for mutations that should not be repeatable.
Subscriptions
Subscriptions typically use WebSocket; auth model differs from regular HTTP:
- GQL-SUB-1 WebSocket connection initialization receives auth token (typically in
connection_params); validated before subscription is established.
- GQL-SUB-2 Subscription resolvers re-check auth on every emit — long-running subscription with stale auth that was revoked but never re-checked = security hole.
- GQL-SUB-3 Subscription topic filters scoped to the authenticated user — never let a client subscribe to "all events" with a server-side filter that may have bugs.
Error messages
- GQL-ERR-1 Production errors don't reveal internal details (stack traces, DB schema, internal IDs).
- GQL-ERR-2 Apollo Server's
formatError / yoga's maskedErrors configured to strip detail from non-known errors.
- GQL-ERR-3 Error codes (
extensions.code) don't leak existence vs permission. Same advice as REST: 404 not 403 for "not authorized to read this resource you can't know exists".
- GQL-ERR-4 Schema syntax errors in production don't echo back the query verbatim (could log it server-side though).
CSRF on GraphQL endpoints
The GraphQL endpoint /graphql accepts POST with application/json. Combined with CORS misconfig, this can be a CSRF vector for mutations.
- GQL-CSRF-1 GraphQL endpoint requires a non-form-submittable Content-Type (
application/json) — most servers do by default, but verify the server enforces this (Apollo Server 4 enforces by default; older versions or custom integrations may not).
- GQL-CSRF-2 Apollo CSRF Prevention enabled (
csrfPrevention: true — default in v4).
- GQL-CSRF-3 SameSite cookies for session — same as REST.
Federation / gateway
If using Apollo Federation or similar:
- GQL-FED-1 Subgraph endpoints not directly reachable from the public internet — only via the gateway.
- GQL-FED-2 Each subgraph applies auth/authz itself (don't trust the gateway to filter).
- GQL-FED-3 Federation context propagation (auth claims) signed or otherwise verifiable between gateway and subgraph.
Specific library notes
Apollo Server 4+:
- Defaults are good: introspection off in production via NODE_ENV, CSRF prevention on, landing page disabled in production.
- Use
ApolloServerPluginUsageReporting carefully — it sends query data to Apollo Studio; ensure no PII in queries (or anonymize).
graphql-yoga:
- Built-in
maskedErrors, useResponseCache. Verify useGraphqlArmor plugin enabled for production.
- Yoga 4+ includes GraphQL Armor by default.
Mercurius (Fastify):
graphiql: true in production = bad. Check the config.
ide: 'graphiql' | 'playground' | false.
Hasura:
- Permissions configured per role per table — review every role's permissions.
- Webhook / JWT auth setup verified.
HASURA_GRAPHQL_ADMIN_SECRET only used for admin operations, never exposed.
PostGraphile:
- Built on Postgres RLS for permission — see
saas-security-pack/supabase-security-audit/references/rls-patterns.md (same patterns).
Phase 4: Triage
Critical class examples:
- No depth/complexity limits → DoS via single query
- Introspection on in production → schema fully discovered by attacker
- Field-level auth missing on PII-bearing fields
- Mutation accepting userId argument without verifying caller
- Persisted queries advertised but bypass route accepting arbitrary queries
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with GQL-.
References
references/field-level-auth.md — Patterns for declarative field authorization (directives, GraphQL Shield, middleware)
1---2name: graphql-security3description: Security audit for GraphQL APIs covering query depth and complexity limits, introspection exposure, field-level authorization, mutation auth, persisted queries, batching abuse, error message leakage, subscription auth, and Apollo/urql/graphql-yoga/Mercurius/Hasura/PostGraphile-specific patterns. Use this skill whenever the user mentions GraphQL, Apollo Server, Apollo Client, urql, graphql-yoga, Mercurius, Hasura, PostGraphile, Strawberry (Python), gqlgen (Go), resolvers, schema.graphql, .gql files, query depth, query complexity, or asks "audit my GraphQL", "GraphQL security review", "depth limit", "persisted queries". Trigger when the codebase contains `.graphql`/`.gql` files, `apollo-server`, `@apollo/server`, `graphql-yoga`, `mercurius`, or `graphql` packages.4---56# GraphQL Security Audit78Audit GraphQL APIs for vulnerabilities specific to the query language and protocol: complexity attacks, introspection leaks, field-level auth bypass, mutation gaps.910## When this skill applies1112- Reviewing GraphQL schemas and resolvers13- Auditing depth / complexity / cost limits14- Reviewing field-level authorization15- Checking introspection exposure in production16- Auditing persisted queries setup17- Reviewing GraphQL subscriptions and WebSocket auth1819Use other skills for: REST API patterns (`saas-security-pack/saas-api-security`), backend framework auth wiring (`nodejs-express-security`, `nestjs-security`, `fastapi-security`), IDOR patterns generally (`saas-security-pack/saas-code-security-review`).2021## Workflow2223Follow `../_shared/audit-workflow.md`. GraphQL-specific notes below.2425### Phase 1: Stack detection2627```bash28# Find the GraphQL server library29grep -E '"(apollo-server|@apollo/server|graphql-yoga|mercurius|express-graphql|@nestjs/graphql|graphql-tools)":' package.json3031# Schema files32find . \( -name '*.graphql' -o -name '*.gql' -o -name 'schema.ts' \) -not -path '*/node_modules/*' | head -103334# Resolver locations35grep -rln 'Query:\|Mutation:\|Subscription:\|resolvers\s*=' src/ | head -1036```3738### Phase 2: Inventory3940```bash41# Endpoint configuration42grep -rnE 'graphqlPath|graphqlMiddleware|playground|introspection' src/4344# Auth context setup45grep -rn 'context:\s*\(' src/ | head4647# Subscriptions (often a separate auth flow)48grep -rn 'Subscription\|subscribe\|WebSocketServer\|graphql-ws' src/4950# Federation (gateway / subgraph patterns)51grep -rn '@apollo/gateway\|buildSubgraphSchema' src/52```5354### Phase 3: Detection — the checks5556#### Introspection in production5758- **GQL-INTRO-1** Introspection disabled in production OR access-controlled.59 ```ts60 // Apollo Server 461 const server = new ApolloServer({62 schema,63 introspection: process.env.NODE_ENV !== 'production',64 });65 ```66- **GQL-INTRO-2** Even if "disabled", schema can sometimes be inferred via error messages. Sanitize error responses (see GQL-ERR-1).67- **GQL-INTRO-3** Persisted queries + introspection-disabled = strong stance; client doesn't need introspection if queries are pre-registered.6869If introspection must stay on (internal tooling), gate by auth — only authenticated admins.7071#### Query depth and complexity7273GraphQL queries can recurse via cyclic schema references. Without limits, a single query can cost megabytes of work:7475```graphql76query Evil {77 user(id: "1") {78 friends { friends { friends { friends { ... } } } }79 }80}81```8283- **GQL-DEPTH-1** Depth limit configured (typical: 10-15). Libraries: `graphql-depth-limit`, built-in to graphql-yoga, configurable in Apollo.84- **GQL-COMP-1** Complexity / cost analysis configured. Libraries: `graphql-query-complexity`, GraphQL Armor.85- **GQL-COMP-2** Per-field cost annotations applied to expensive fields (search, full-text, pagination over large sets).86- **GQL-COMP-3** Alias attack mitigated: counting aliases toward complexity (`a: thing b: thing c: thing` should count as 3, not 1).87- **GQL-COMP-4** Directives like `@stream` and `@defer` (newer specs) have their own cost accounting if enabled.8889```ts90import { createComplexityLimitRule } from 'graphql-validation-complexity';91import depthLimit from 'graphql-depth-limit';9293const server = new ApolloServer({94 schema,95 validationRules: [96 depthLimit(10),97 createComplexityLimitRule(1000, {98 scalarCost: 1,99 objectCost: 2,100 listFactor: 10,101 }),102 ],103});104```105106GraphQL Armor combines all these defaults in one plugin and is the lowest-friction way to deploy them.107108#### Field-level authorization109110REST has one auth check per endpoint; GraphQL has one auth surface per field. The mistake is checking at the root query and trusting the rest.111112- **GQL-AZ-1** Resolvers for fields exposing other users' data check authorization on the parent object, not just the root.113 ```ts114 // BAD — root checks auth but `users` resolver doesn't115 const resolvers = {116 Query: {117 currentUser: requireAuth((parent, args, ctx) => ctx.user),118 },119 User: {120 // Wide-open: anyone who reaches a User can ask for any field121 email: (parent) => parent.email,122 paymentMethods: (parent) => db.paymentMethods.findMany({ where: { userId: parent.id } }),123 },124 };125 ```126 An attacker who navigates to a User from elsewhere (e.g., via a comment's `author` field) gets their email and payment methods.127128- **GQL-AZ-2** Use directives or middleware for declarative field authz:129 ```graphql130 directive @auth(requires: Role = USER) on FIELD_DEFINITION131 132 type User {133 id: ID!134 displayName: String!135 email: String! @auth(requires: SELF_OR_ADMIN)136 paymentMethods: [PaymentMethod!]! @auth(requires: SELF)137 }138 ```139 Implement the directive to check `ctx.user` against the parent object on every field.140141- **GQL-AZ-3** Tools: GraphQL Shield (rule-based authz), Apollo Connectors with auth context, custom middleware. Don't roll your own per-resolver if you have more than ~20 resolvers — the surface is too large to maintain manually.142143#### Mutations144145- **GQL-MUT-1** Every mutation checks auth + authz. Sensitive mutations (delete account, change billing, admin actions) require additional verification (MFA, re-auth).146- **GQL-MUT-2** Mutations don't trust input IDs — derive `userId` / `tenantId` from `ctx`, not from arguments.147- **GQL-MUT-3** Mutations idempotent or rate-limited where appropriate.148- **GQL-MUT-4** Input validation via schema or in resolver (use zod / yup / ajv at the resolver boundary; GraphQL types don't validate ranges or formats).149150#### Persisted queries151152Persisted queries dramatically reduce attack surface: clients send a query hash, server looks up the actual query. Attackers can't send arbitrary queries.153154- **GQL-PQ-1** Persisted queries enabled for first-party clients (web, mobile).155- **GQL-PQ-2** Public/external clients (third-party developers, partners) — either allow arbitrary queries with strict depth/complexity, or define a partner API with persisted queries.156- **GQL-PQ-3** Persisted query registry write access restricted to CI/CD pipeline; not writeable from production.157- **GQL-PQ-4** Apollo Persisted Query Manifest workflow or equivalent in use.158159#### Batching abuse160161```graphql162# Single request, 1000 mutations163mutation BatchEvil {164 m1: addCredit(userId: "self", amount: 1000)165 m2: addCredit(userId: "self", amount: 1000)166 m3: addCredit(userId: "self", amount: 1000)167 # ...168}169```170171- **GQL-BAT-1** Limit alias count and operation count per request.172- **GQL-BAT-2** Rate limit mutations server-side per user/tenant — within a single request and across requests.173- **GQL-BAT-3** Idempotency keys for mutations that should not be repeatable.174175#### Subscriptions176177Subscriptions typically use WebSocket; auth model differs from regular HTTP:178179- **GQL-SUB-1** WebSocket connection initialization receives auth token (typically in `connection_params`); validated before subscription is established.180- **GQL-SUB-2** Subscription resolvers re-check auth on every emit — long-running subscription with stale auth that was revoked but never re-checked = security hole.181- **GQL-SUB-3** Subscription topic filters scoped to the authenticated user — never let a client subscribe to "all events" with a server-side filter that may have bugs.182183#### Error messages184185- **GQL-ERR-1** Production errors don't reveal internal details (stack traces, DB schema, internal IDs).186- **GQL-ERR-2** Apollo Server's `formatError` / yoga's `maskedErrors` configured to strip detail from non-known errors.187- **GQL-ERR-3** Error codes (`extensions.code`) don't leak existence vs permission. Same advice as REST: 404 not 403 for "not authorized to read this resource you can't know exists".188- **GQL-ERR-4** Schema syntax errors in production don't echo back the query verbatim (could log it server-side though).189190#### CSRF on GraphQL endpoints191192The GraphQL endpoint `/graphql` accepts POST with `application/json`. Combined with CORS misconfig, this can be a CSRF vector for mutations.193194- **GQL-CSRF-1** GraphQL endpoint requires a non-form-submittable Content-Type (`application/json`) — most servers do by default, but verify the server enforces this (Apollo Server 4 enforces by default; older versions or custom integrations may not).195- **GQL-CSRF-2** Apollo CSRF Prevention enabled (`csrfPrevention: true` — default in v4).196- **GQL-CSRF-3** SameSite cookies for session — same as REST.197198#### Federation / gateway199200If using Apollo Federation or similar:201202- **GQL-FED-1** Subgraph endpoints not directly reachable from the public internet — only via the gateway.203- **GQL-FED-2** Each subgraph applies auth/authz itself (don't trust the gateway to filter).204- **GQL-FED-3** Federation context propagation (auth claims) signed or otherwise verifiable between gateway and subgraph.205206#### Specific library notes207208**Apollo Server 4+:**209- Defaults are good: introspection off in production via NODE_ENV, CSRF prevention on, landing page disabled in production.210- Use `ApolloServerPluginUsageReporting` carefully — it sends query data to Apollo Studio; ensure no PII in queries (or anonymize).211212**graphql-yoga:**213- Built-in `maskedErrors`, `useResponseCache`. Verify `useGraphqlArmor` plugin enabled for production.214- Yoga 4+ includes GraphQL Armor by default.215216**Mercurius (Fastify):**217- `graphiql: true` in production = bad. Check the config.218- `ide: 'graphiql' | 'playground' | false`.219220**Hasura:**221- Permissions configured per role per table — review every role's permissions.222- Webhook / JWT auth setup verified.223- `HASURA_GRAPHQL_ADMIN_SECRET` only used for admin operations, never exposed.224225**PostGraphile:**226- Built on Postgres RLS for permission — see `saas-security-pack/supabase-security-audit/references/rls-patterns.md` (same patterns).227228### Phase 4: Triage229230Critical class examples:231- No depth/complexity limits → DoS via single query232- Introspection on in production → schema fully discovered by attacker233- Field-level auth missing on PII-bearing fields234- Mutation accepting userId argument without verifying caller235- Persisted queries advertised but bypass route accepting arbitrary queries236237### Phase 5: Report238239Use `../_shared/findings-schema.md`. Prefix IDs with `GQL-`.240241## References242243- `references/field-level-auth.md` — Patterns for declarative field authorization (directives, GraphQL Shield, middleware)