Why GraphQL needs different security thinking

REST APIs have a fixed attack surface — each endpoint returns a predictable response. GraphQL exposes a single endpoint with a flexible query language, which means traditional API security patterns don't translate directly. Rate limiting by endpoint doesn't work when every request goes to /graphql. Input validation changes when the client controls the query structure. Authorization gets complex when nested resolvers return data from different access levels.

Nigerian fintechs increasingly adopt GraphQL for mobile banking apps and internal dashboards. The flexibility is appealing, but the security surface is wider than most teams realise.

1. Query depth limiting

GraphQL schemas are often deeply nested. A user has transactions, transactions have merchants, merchants have locations, locations have regions. Without depth limiting, an attacker can craft a recursive query that traverses these relationships indefinitely, consuming massive server resources:

# Malicious deeply nested query
query {
  user(id: "1") {
    transactions {
      merchant {
        transactions {
          user {
            transactions {
              merchant {
                # ...infinite nesting
              }
            }
          }
        }
      }
    }
  }
}

Implement depth limiting using libraries like graphql-depth-limit (Node.js) or equivalent middleware. A depth limit of 5–7 is reasonable for most fintech schemas. Apply it as a validation rule before query execution, not after.

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)]
});

2. Query complexity analysis

Depth limiting alone isn't enough. A flat query requesting every field on a list of 10,000 transactions is shallow but expensive. Query complexity analysis assigns a cost to each field and resolver, then rejects queries that exceed a threshold:

# Flat but expensive query
query {
  allTransactions(first: 10000) {
    id
    amount
    status
    merchant { name, address, category }
    user { name, email, phone }
  }
}

Use graphql-query-complexity or Apollo Server's built-in cost analysis. Assign higher costs to fields that trigger database joins or external API calls. Set a maximum cost per query and return a clear error when exceeded. This prevents denial-of-service through resource-exhausting queries without limiting legitimate usage.

3. Disable introspection in production

GraphQL introspection lets clients query the schema itself — every type, field, argument, and relationship. In development, this powers tools like GraphiQL and Apollo Studio. In production, it hands attackers a complete map of your API.

// Disable introspection in Apollo Server
const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== 'production'
});

With introspection enabled, an attacker immediately discovers admin-only fields, internal mutations, deprecated but still functional endpoints, and the exact data model of your financial records. Disable it in production. If your frontend team needs schema awareness, use a schema registry or static schema files — not live introspection on production.

Pentest finding

Introspection revealed admin mutations

During a GraphQL API assessment for a Nigerian digital banking platform, introspection was enabled in production. We discovered an adminUpdateBalance mutation that was undocumented but fully functional. It required authentication but no admin role check — any authenticated user could modify any account balance directly.

4. Field-level authorization

GraphQL's nested resolver pattern creates a subtle authorization problem. A user query might be properly authenticated, but a nested field — say user.transactions.merchant.revenue — might resolve data the requesting user shouldn't access. Each resolver in the chain needs its own authorization check.

Don't rely on schema design to enforce access control. A field that "only admins would query" is still queryable by anyone unless you enforce it in the resolver. Implement authorization at the resolver level using directives or middleware:

const resolvers = {
  User: {
    transactions: (parent, args, context) => {
      // Verify the requesting user owns this user record
      if (parent.id !== context.user.id && !context.user.isAdmin) {
        throw new ForbiddenError('Not authorized');
      }
      return getTransactions(parent.id);
    }
  }
};

This is the GraphQL equivalent of BOLA/IDOR vulnerabilities in REST APIs. The shape of the problem is different (nested resolvers vs. endpoint paths), but the impact is identical: unauthorized data access.

5. Batching attacks

GraphQL supports query batching — sending multiple queries in a single HTTP request. An attacker can abuse this to bypass rate limiting (one HTTP request contains 1,000 login attempts) or to perform alias-based enumeration:

# Alias-based brute force in a single request
query {
  a1: login(email: "target@email.com", password: "password1") { token }
  a2: login(email: "target@email.com", password: "password2") { token }
  a3: login(email: "target@email.com", password: "password3") { token }
  # ...hundreds more aliases
}

Defences: limit the number of operations per batched request, limit aliases per query, and apply rate limiting at the operation level (not the HTTP request level). Track login attempts by identity, not by IP or request count. See rate limiting for payment APIs for broader patterns.

6. Persisted queries

Instead of allowing arbitrary queries from clients, use persisted (or allowlisted) queries. During build time, extract all GraphQL queries from your frontend code, hash them, and register the hashes with your server. At runtime, clients send only the hash — the server looks up and executes the registered query.

This eliminates an entire class of attacks: injection via custom queries, resource exhaustion via crafted queries, and schema exploration. It also improves performance by reducing payload size. Apollo Server supports Automatic Persisted Queries (APQ) out of the box.

For fintech APIs with a defined set of client applications, persisted queries are the strongest defence. If you also have a public API for third-party integrations, you'll need to combine persisted queries with the other defences described above.

Running a GraphQL API for your fintech product? We test for depth attacks, authorization bypasses, and the full range of GraphQL-specific vulnerabilities.

Book a GraphQL security audit

7. N+1 queries and auth bypass

The N+1 problem in GraphQL isn't just a performance issue — it can be a security issue. DataLoader batches resolver calls for efficiency, but if your authorization logic assumes resolvers execute individually, batching can bypass per-item checks. Ensure your DataLoader implementations include authorization in the batch function, not just the resolver that triggers the load.

Also verify that error handling in batch resolvers doesn't leak information. A partially authorized batch request should return null or an error for unauthorized items without revealing that the items exist (preventing enumeration).

Key takeaway

GraphQL's flexibility is its security challenge

Every feature that makes GraphQL powerful — nested queries, introspection, batching, flexible field selection — is also an attack vector. Depth limiting, complexity analysis, disabled introspection, field-level auth, anti-batching controls, and persisted queries aren't optional hardening. They're the baseline for any GraphQL API handling financial data.

Related reading

Blog: BOLA API vulnerabilities · Secure your fintech API · Rate limiting for payment APIs

Guides: OWASP for fintech · Pentest tools and methodology · Web app pentesting

Services: API security · Penetration testing · Secure architecture review