GraphQL Performance and Security Testing: N+1, DoS, Injection, and More

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: 200

Run 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.

Read more

Start now free