GraphQL Performance and Security Testing: N+1, DoS, Injection, and More
GraphQL's flexibility is a double-edged sword. The same feature that lets clients fetch exactly what they need also lets an attacker craft a query that brings your server to its knees. The same resolver pattern that makes GraphQL elegant also creates N+1 query problems at scale.
Performance and security testing isn't optional for production GraphQL APIs — it's the discipline that keeps GraphQL's flexibility from becoming a liability.
Performance Testing
Detecting N+1 Query Problems
The N+1 problem is GraphQL's most common performance issue. A query that fetches a list of posts and their authors triggers one database query for posts, then one query per post for the author — N+1 total.
// The dangerous query
const POSTS_WITH_AUTHORS = gql`
query {
posts { # 1 DB query
id
title
author { # N DB queries — one per post
name
avatar
}
}
}
`To detect N+1 in tests, count database queries:
import { createQueryCounter } from './test-utils/db-counter'
describe('N+1 detection', () => {
it('fetches posts with authors in 2 queries, not N+1', async () => {
const counter = createQueryCounter()
// Seed 20 posts with different authors
await seedPosts(20)
await client.request(POSTS_WITH_AUTHORS)
// Should be 2 queries: one for posts, one for all authors (via DataLoader)
expect(counter.queryCount).toBeLessThanOrEqual(2)
counter.reset()
})
})// test-utils/db-counter.js — works with Knex, Prisma, Sequelize
export function createQueryCounter() {
let count = 0
// Knex event listener
knex.on('query', () => { count++ })
return {
get queryCount() { return count },
reset() { count = 0 },
}
}For Prisma, use query event logging:
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
})
let queryCount = 0
prisma.$on('query', () => { queryCount++ })
afterEach(() => { queryCount = 0 })
it('loads user with posts in max 2 queries', async () => {
await client.request(USER_WITH_POSTS, { id: '1' })
expect(queryCount).toBeLessThanOrEqual(2)
})DataLoader Correctness Testing
DataLoader is the standard fix for N+1 problems. Test that it actually batches correctly:
import DataLoader from 'dataloader'
import { userLoader } from '../loaders/user-loader'
describe('userLoader', () => {
it('batches multiple IDs into a single query', async () => {
const dbQuerySpy = jest.spyOn(db, 'query')
// Request multiple users in the same tick
const [user1, user2, user3] = await Promise.all([
userLoader.load('1'),
userLoader.load('2'),
userLoader.load('3'),
])
// DataLoader should batch into 1 query
expect(dbQuerySpy).toHaveBeenCalledTimes(1)
expect(dbQuerySpy).toHaveBeenCalledWith(
expect.stringContaining('WHERE id IN'),
['1', '2', '3']
)
expect(user1.name).toBeDefined()
expect(user2.name).toBeDefined()
expect(user3.name).toBeDefined()
})
it('caches repeated loads within the same request', async () => {
const dbQuerySpy = jest.spyOn(db, 'query')
// Same ID loaded twice
const [user1a, user1b] = await Promise.all([
userLoader.load('1'),
userLoader.load('1'),
])
// Only 1 query due to caching
expect(dbQuerySpy).toHaveBeenCalledTimes(1)
expect(user1a).toBe(user1b) // same reference
})
it('returns null for missing IDs, not an error', async () => {
const result = await userLoader.load('nonexistent-id')
expect(result).toBeNull()
})
})Response Time Testing
Test that queries complete within acceptable time bounds:
describe('query response times', () => {
const ACCEPTABLE_P95_MS = 200
it('user query responds within 200ms', async () => {
const times = []
for (let i = 0; i < 20; i++) {
const start = performance.now()
await client.request(GET_USER, { id: '1' })
times.push(performance.now() - start)
}
times.sort((a, b) => a - b)
const p95 = times[Math.floor(times.length * 0.95)]
expect(p95).toBeLessThan(ACCEPTABLE_P95_MS)
})
it('search query responds within 500ms with 10k records', async () => {
await seedUsers(10000)
const start = performance.now()
await client.request(SEARCH_USERS, { query: 'test' })
const duration = performance.now() - start
expect(duration).toBeLessThan(500)
})
})Load Testing with Artillery
For sustained load testing of GraphQL endpoints:
# artillery-graphql.yml
config:
target: http://localhost:4000
phases:
- duration: 60
arrivalRate: 10
rampTo: 100
scenarios:
- name: Mixed GraphQL Operations
weight: 70
flow:
- post:
url: /graphql
json:
operationName: GetUser
query: "query GetUser($id: ID!) { user(id: $id) { id name email } }"
variables:
id: "{{ $randomInt(1, 1000) }}"
expect:
- statusCode: 200
- hasProperty: "data.user"
- name: Heavy Nested Query
weight: 30
flow:
- post:
url: /graphql
json:
operationName: GetUserWithPosts
query: |
query GetUserWithPosts($id: ID!) {
user(id: $id) {
id
name
posts(first: 10) {
id
title
comments(first: 5) {
text
author { name }
}
}
}
}
variables:
id: "{{ $randomInt(1, 100) }}"
expect:
- statusCode: 200Run with: artillery run artillery-graphql.yml --output report.json
Security Testing
Query Depth Attack
An attacker can craft a deeply nested query to exhaust server resources:
// A malicious depth-bomb query
const DEPTH_BOMB = gql`
query {
user(id: "1") {
friends {
friends {
friends {
friends {
friends {
friends {
name # 6 levels deep — much worse in the real attack
}
}
}
}
}
}
}
}
`Test that your depth limit catches this:
describe('query depth limiting', () => {
it('rejects queries exceeding depth limit (10)', async () => {
// Build a query that exceeds depth 10
let deepQuery = 'query { user(id: "1") {'
for (let i = 0; i < 10; i++) {
deepQuery += ' friends {'
}
deepQuery += ' name '
deepQuery += '}'.repeat(11)
deepQuery += ' }'
await expect(
client.request(deepQuery)
).rejects.toMatchObject({
response: {
errors: expect.arrayContaining([
expect.objectContaining({
message: expect.stringMatching(/depth/i)
})
])
}
})
})
it('allows queries within depth limit', async () => {
// This should succeed — it's within the limit
const data = await client.request(gql`
query {
user(id: "1") {
posts {
comments {
author {
name
}
}
}
}
}
`)
expect(data.user).toBeDefined()
})
})Query Complexity Attack
Wide queries (many fields, large pagination limits) can be as destructive as deep ones:
describe('query complexity limiting', () => {
it('rejects queries with excessive complexity', async () => {
// Request a massive list with expensive nested fields
await expect(
client.request(gql`
query {
users(first: 1000) {
posts(first: 100) {
comments(first: 50) {
likes(first: 50) {
user {
name
}
}
}
}
}
}
`)
).rejects.toMatchObject({
response: {
errors: expect.arrayContaining([
expect.objectContaining({
message: expect.stringMatching(/complexity/i)
})
])
}
})
})
it('returns complexity score in extensions when configured', async () => {
const response = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '{ user(id: "1") { name email } }'
})
})
const body = await response.json()
// Server returns complexity in extensions for debugging
expect(body.extensions?.complexity).toBeDefined()
expect(body.extensions.complexity).toBeLessThan(10)
})
})Introspection Attack
In production, introspection should be disabled to prevent attackers from mapping your schema:
describe('introspection security', () => {
it('disables introspection in production', async () => {
if (process.env.NODE_ENV !== 'production') {
return // Skip in development
}
await expect(
client.request(gql`
query {
__schema {
types {
name
}
}
}
`)
).rejects.toMatchObject({
response: {
errors: expect.arrayContaining([
expect.objectContaining({
message: expect.stringMatching(/introspection/i)
})
])
}
})
})
it('allows field suggestions to be disabled', async () => {
// Field suggestions can leak schema information even without introspection
await expect(
client.request(gql`
query {
usr(id: "1") { name } # typo: should be "user"
}
`)
).rejects.toMatchObject({
response: {
errors: expect.arrayContaining([
expect.objectContaining({
// Should NOT say "Did you mean user?" in production
message: expect.not.stringMatching(/did you mean/i)
})
])
}
})
})
})GraphQL Injection Testing
Unlike SQL injection, GraphQL injection exploits happen when user input is interpolated into query strings instead of using variables:
// VULNERABLE pattern — never do this
const unsafeQuery = `
query {
user(email: "${userInput}") {
id name
}
}
`
// SAFE pattern — always use variables
const safeQuery = gql`
query GetUser($email: String!) {
user(email: $email) {
id name
}
}
`
await client.request(safeQuery, { email: userInput })Test that your API rejects injection attempts:
describe('GraphQL injection prevention', () => {
const injectionPayloads = [
'") { id name } maliciousQuery(arg: "',
'\\") { sensitiveField } #',
'${process.env.SECRET}',
'a") { __typename } query IntrospectionQuery { __schema',
]
injectionPayloads.forEach((payload) => {
it(`handles injection payload: ${payload.substring(0, 30)}...`, async () => {
// If your server uses parameterized variables, injection attempts
// should just be treated as literal string values
const result = await client.request(gql`
query SearchUser($email: String!) {
user(email: $email) { id }
}
`, { email: payload })
// Should return null (no user with that email) — not an error or data leak
expect(result.user).toBeNull()
})
})
})Authorization Bypass Testing
Test every authorization rule with a matrix of user roles:
const authMatrix = [
{ operation: 'viewPublicPost', roles: { anonymous: true, user: true, admin: true } },
{ operation: 'viewPrivateDraft', roles: { anonymous: false, user: false, admin: true } },
{ operation: 'deleteAnyPost', roles: { anonymous: false, user: false, admin: true } },
{ operation: 'deleteOwnPost', roles: { anonymous: false, user: true, admin: true } },
{ operation: 'viewAllUsers', roles: { anonymous: false, user: false, admin: true } },
]
describe('authorization matrix', () => {
authMatrix.forEach(({ operation, roles }) => {
Object.entries(roles).forEach(([role, shouldSucceed]) => {
it(`${operation} — ${role}: ${shouldSucceed ? 'allowed' : 'denied'}`, async () => {
const client = await getClientForRole(role)
if (shouldSucceed) {
await expect(executeOperation(client, operation)).resolves.toBeDefined()
} else {
await expect(executeOperation(client, operation)).rejects.toMatchObject({
response: {
errors: expect.arrayContaining([
expect.objectContaining({
extensions: expect.objectContaining({
code: expect.stringMatching(/UNAUTHORIZED|FORBIDDEN/i)
})
})
])
}
})
}
})
})
})
})Object-Level Authorization (BOLA/IDOR)
Test that users can't access other users' private data by guessing IDs:
describe('object-level authorization', () => {
let user1Client, user2Client
let user1Post
beforeAll(async () => {
user1Client = await getAuthenticatedClient('user-1@example.com', 'password')
user2Client = await getAuthenticatedClient('user-2@example.com', 'password')
// user1 creates a private draft
const { createPost } = await user1Client.request(gql`
mutation {
createPost(title: "Private Draft", visibility: PRIVATE) {
id
}
}
`)
user1Post = createPost
})
it('owner can access their own private post', async () => {
const data = await user1Client.request(gql`
query GetPost($id: ID!) {
post(id: $id) { id title }
}
`, { id: user1Post.id })
expect(data.post.title).toBe('Private Draft')
})
it('other user cannot access private post by ID', async () => {
// user2 tries to access user1's private post by ID
const data = await user2Client.request(gql`
query GetPost($id: ID!) {
post(id: $id) { id title }
}
`, { id: user1Post.id })
// Should return null, not throw — not revealing that the post exists
expect(data.post).toBeNull()
})
})Rate Limiting Verification
describe('rate limiting', () => {
it('blocks excessive requests from the same IP', async () => {
const requests = Array.from({ length: 200 }, () =>
fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
})
)
const responses = await Promise.all(requests)
const rateLimited = responses.filter(r => r.status === 429)
expect(rateLimited.length).toBeGreaterThan(0)
})
it('includes retry-after header on rate limit response', async () => {
// Exhaust the rate limit
for (let i = 0; i < 150; i++) {
await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
})
}
const response = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
})
if (response.status === 429) {
expect(response.headers.get('Retry-After')).not.toBeNull()
}
})
})Monitoring in Production
Security testing doesn't stop at CI. Set up monitoring for anomalous query patterns:
// Apollo Server plugin for query complexity monitoring
const complexityMonitorPlugin = {
requestDidStart() {
return {
didResolveOperation({ request, document }) {
const complexity = calculateComplexity(document)
// Log high-complexity queries for review
if (complexity > 50) {
logger.warn('High complexity query', {
operationName: request.operationName,
complexity,
clientIp: request.http?.headers.get('x-forwarded-for'),
})
metrics.histogram('graphql.query.complexity', complexity, {
operation: request.operationName,
})
}
},
}
},
}Track these metrics in production:
- Query complexity distribution
- Resolver execution time by field
- Error rate by operation
- N+1 indicator: database query count per GraphQL request
- Rate limit hit frequency by IP/token
Summary
GraphQL performance and security testing covers two threat models:
Performance threats: N+1 queries, deep nesting, wide pagination, missing DataLoader batching. Test with query counters, response time assertions, and load testing.
Security threats: query depth/complexity bombs, introspection leaks, injection via string interpolation, authorization bypasses (field-level and object-level), rate limit evasion.
The enforcement mechanisms are:
- Query depth limiting (e.g.,
graphql-depth-limit) - Query complexity scoring (e.g.,
graphql-query-complexity) - DataLoader for all list resolvers
- Parameterized variables (never string interpolation)
- Field-level and object-level authorization checks
- Rate limiting at the network or application layer
Test all of them explicitly. "We have depth limiting" is not a security claim — "here's the test that proves depth limiting fires at depth 11" is.