DEV Community

Stefan
Stefan

Posted on • Originally published at codereviewlab.com

GraphQL Authorization Bypass: A Real CVE Code Review

Real-World GraphQL Authorization Bypass CVE Example Code Review

A tenant isolation bug in a GraphQL API differs from a REST IDOR in one uncomfortable way: the bypass often doesn't require a forged token, a path traversal, or a malformed request. The attacker sends a perfectly valid query, the server processes it correctly, and the authorization logic never fires because it was wired to the wrong layer. CVE-2023-26489 (wasmCloud host bypass) and a cluster of similar bugs in Apollo-based APIs share the same skeleton: query-root guards that protect the entry point while nested resolvers and aliases silently skip the check entirely.

How the GraphQL Authorization Bypass Works

GraphQL's resolver tree is the thing that makes this class of bug distinct. In a REST API, authorization lives in middleware that runs before the route handler — one route, one check. In GraphQL, a single HTTP POST to /graphql can resolve dozens of fields, each through its own resolver function. If you only check authorization at the root resolver (the entry point the client names in the query), every nested resolver below it inherits no protection by default.

The alias primitive makes this worse. A client can rename any field in their query with fieldName: actualField, which means rate limiting and allow-listing on field names breaks down immediately. Combined with fragments and batched operations, a single request can probe multiple objects across trust boundaries.

Here is the vulnerable Apollo Server pattern. Note there is no error handling on the attacker path — that's intentional, because the vulnerable code genuinely has none:

// resolvers.js — vulnerable pattern
const resolvers = {
  Query: {
    // Auth check here: only logged-in users reach this resolver
    me: (_, __, { user }) => {
      if (!user) throw new AuthenticationError("Not logged in");
      return user;
    },

    // No auth check at all — any caller can pass an arbitrary id
    user: (_, { id }) => {
      return db.users.findById(id);
    },
  },

  User: {
    // No ownership check — any User object returned above exposes this
    privateProfile: (parent) => {
      return db.profiles.findPrivateByUserId(parent.id);
    },

    email: (parent) => parent.email,
  },
};
Enter fullscreen mode Exit fullscreen mode

An attacker authenticated as user A sends this query to read user B's private data:

query StealProfile {
  victimData: user(id: "B") {
    email
    privateProfile {
      ssn
      dateOfBirth
      stripeCustomerId
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The me guard never runs. user(id) returns whatever db.users.findById finds. The privateProfile resolver happily fetches the associated record because it only receives the parent User object — it has no way to know whether the caller owns that user unless you explicitly pass the request context and check it.

The alias (victimData: user(...)) is a red herring here — the bypass works without it. The alias just helps evade naive field-name logging and rate limits that watch for repeated user calls.

This is the pattern the GraphQL security code review lab on Code Review Lab uses to train reviewers to trace authorization through the full resolver tree, not just the query root.

The Fix: Field-Level Authorization in Resolvers

The minimal fix is to push the authorization check into every sensitive resolver so it executes regardless of how the field was reached — direct query, nested traversal, fragment, or alias.

Two patterns work well in production. The first is a context-aware check inside the resolver itself. The second is a schema directive that applies the same check declaratively and survives schema stitching.

// resolvers.js — patched
const { ForbiddenError } = require("apollo-server-errors");

const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) throw new AuthenticationError("Not logged in");
      return user;
    },

    user: (_, { id }, { user }) => {
      // Authenticated callers only — no anonymous traversal
      if (!user) throw new AuthenticationError("Not logged in");
      return db.users.findById(id);
    },
  },

  User: {
    privateProfile: (parent, _, { user }) => {
      // Ownership check lives here, not at the query root,
      // so it fires no matter which path reached this resolver
      if (!user || user.id !== parent.id) {
        throw new ForbiddenError("Access denied");
      }
      return db.profiles.findPrivateByUserId(parent.id);
    },

    email: (parent, _, { user }) => {
      // Even email is scoped — admins see all, owners see own
      if (!user) throw new AuthenticationError("Not logged in");
      if (user.role !== "ADMIN" && user.id !== parent.id) {
        throw new ForbiddenError("Access denied");
      }
      return parent.email;
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

For teams that want the check to be impossible to accidentally omit during schema growth, graphql-shield applies rules as a middleware layer that wraps every resolver:

// permissions.js — graphql-shield rule tree
const { shield, rule, and } = require("graphql-shield");

const isAuthenticated = rule({ cache: "contextual" })(
  async (parent, args, ctx) => ctx.user !== null
);

const isOwner = rule({ cache: "strict" })(
  // parent here is the User object whose field is being resolved
  async (parent, args, ctx) => ctx.user?.id === parent.id
);

module.exports = shield({
  Query: {
    user: isAuthenticated,
  },
  User: {
    privateProfile: and(isAuthenticated, isOwner),
    email: and(isAuthenticated, isOwner),
  },
});
Enter fullscreen mode Exit fullscreen mode

The cache: "strict" on isOwner matters: it tells graphql-shield to re-evaluate the rule for every unique (parent, args, context) combination rather than short-circuiting on a previous result from the same request. Without it, a batched query that fetches your own profile first can warm the cache and let subsequent fields for other users pass through.

Reproducing the CVE Locally

The following setup pins a deliberately vulnerable Apollo Server configuration so you can confirm the attack, apply the patch, and re-run to verify the fix. Run this in a throwaway environment.

# docker-compose.yml
version: "3.9"
services:
  api:
    build: .
    ports:
      - "4000:4000"
    environment:
      NODE_ENV: development
      DB_SEED: "true"
    volumes:
      - ./src:/app/src  # mount local resolvers so hot-reload reflects your patch
Enter fullscreen mode Exit fullscreen mode
# seed creates two users: alice (id=1) and bob (id=2)
docker compose up --build

# Attack: authenticated as alice, read bob's privateProfile
curl -s -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(cat tokens/alice.jwt)" \  # alice's valid JWT
  -d '{
    "query": "query { victimData: user(id: \"2\") { email privateProfile { ssn stripeCustomerId } } }"
  }' | jq .
Enter fullscreen mode Exit fullscreen mode

Pre-patch, this returns Bob's SSN and Stripe ID. Post-patch, the privateProfile resolver throws ForbiddenError before touching the database. The user query still resolves (Alice is authenticated), but the sensitive nested fields are blocked at their own resolvers.

One thing to watch: if your test token is an admin token, the patched code above will still return the data because the admin branch is intentional. Use a non-privileged user token when verifying the fix, or your test will produce a false negative.

Code Review Checklist for GraphQL Authz

When reviewing a GraphQL API PR, the question isn't "is there authentication?" — it's "does every resolver that touches sensitive data verify the caller's right to that specific parent object?"

- // Middleware approach (REST-era thinking, doesn't protect nested resolvers)
- app.use("/graphql", authenticate, graphqlHTTP({ schema }));

+ // Authorization inside each sensitive resolver
+ privateProfile: (parent, _, { user }) => {
+   if (!user || user.id !== parent.id) throw new ForbiddenError("Access denied");
+   return db.profiles.findPrivateByUserId(parent.id);
+ }
Enter fullscreen mode Exit fullscreen mode

Specific flags to raise during review:

Trust boundaries per resolver. Every resolver that reads or mutates data scoped to a specific user or tenant needs its own ownership or role check. A check only at the operation root is not sufficient.

Alias abuse surface. If the schema exposes user(id: ID!), any authenticated caller can query any user. Decide whether that's intentional. If not, remove the field or gate it behind an admin role — don't rely on clients not knowing the field exists.

Introspection in production. Introspection enabled on a production API hands the attacker the full field map. They don't need to guess privateProfile exists; the schema tells them. Apollo Server 3+ disables introspection in production by default; earlier versions don't.

Mutation scoping. Authorization bugs in queries leak data. Authorization bugs in mutations write or delete it. The same field-level pattern applies: a updateUser(id, data) mutation must verify the caller owns id, not just that they're authenticated.

Batched operations. Apollo's batching allows an array of operations in one POST. A resolver-level check handles this correctly; a per-request middleware check may only run once for the batch.

JWT claims as implicit trust. A JWT payload saying { "role": "ADMIN" } is only as trustworthy as the signature verification. If the server doesn't verify the signature (or uses alg: none), the entire permission model collapses. Verify claims server-side on every request; don't cache the decoded payload across requests in a way that could be shared across users.

The application security engineer path on Code Review Lab covers the full trust-boundary analysis methodology behind this kind of structured review.

Detecting the Pattern in CI

Static analysis can catch the most common form of this bug: a resolver function that never reads from context.user. It won't catch all cases — a resolver that reads context.user but doesn't compare it against parent.id still has the ownership bug — but it eliminates the obvious omissions before they merge.

# .github/workflows/graphql-security.yml
name: GraphQL Security Lint

on: [pull_request]

jobs:
  graphql-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Run graphql-eslint with auth rules
        run: npx graphql-eslint --config .graphqlrc.yml src/**/*.graphql
        # Fails the build if any field resolver matches the no-auth pattern

      - name: Check resolver auth coverage
        run: node scripts/check-resolver-auth.js
        # Custom script: parses resolver map, flags any resolver
        # that returns sensitive types without referencing ctx.user
Enter fullscreen mode Exit fullscreen mode
// scripts/check-resolver-auth.js
// Parses resolver source with acorn, flags functions that touch
// db.profiles, db.payments, or db.pii without a ctx.user guard

const fs = require("fs");
const acorn = require("acorn");

const SENSITIVE_CALLS = ["findPrivateByUserId", "findPaymentByUserId", "findPiiByUserId"];
const resolverSource = fs.readFileSync("src/resolvers.js", "utf8");
const ast = acorn.parse(resolverSource, { ecmaVersion: 2022 });

// Walk AST looking for CallExpression nodes whose callee
// matches SENSITIVE_CALLS without a prior MemberExpression on ctx.user
// ... domain-specific AST traversal ...

process.exit(violationsFound ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

Wiring this into pull request checks means a new resolver that skips the auth guard fails the build before a reviewer even sees the diff. See how to secure your CI/CD pipeline for the broader pattern of making security checks load-bearing in the build process rather than advisory.

Hardening Beyond the Patch

Field-level auth fixes the specific bypass. These controls reduce the blast radius when the next one surfaces.

Persisted queries restrict the server to a pre-approved set of operations. An attacker can't construct an arbitrary aliased query if the server only executes queries you shipped. This doesn't replace authorization — a persisted query can still have an authz bug — but it eliminates the exploration phase.

Depth and complexity limits prevent deeply nested queries from being used to amplify data extraction or trigger DoS through resolver fan-out. Apollo Server provides both natively.

// apollo-server.js — defense-in-depth config
const { ApolloServer } = require("@apollo/server");
const depthLimit = require("graphql-depth-limit");
const { createComplexityLimitRule } = require("graphql-validation-complexity");

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(7),                              // reject queries nested deeper than 7 levels
    createComplexityLimitRule(1000, {           // reject queries with complexity score > 1000
      onCost: (cost) => console.log("Query cost:", cost),
    }),
  ],
  introspection: process.env.NODE_ENV !== "production", // disable introspection in prod
  persistedQueries: {
    cache: persistedQueriesCache, // APQ: only execute known query hashes
  },
});
Enter fullscreen mode Exit fullscreen mode

Multi-tenant schema design deserves explicit threat modeling. If your schema includes organization(id: ID!) and user(id: ID!) as top-level queries, consider whether tenant-scoping should be enforced at the data layer (row-level security in Postgres, for example) rather than relying entirely on resolver logic. Resolver logic can be forgotten; a database constraint cannot.

If you're building or evaluating APIs beyond GraphQL, the same field-level trust boundary analysis applies to gRPC API security patterns — service-level auth in gRPC has the same failure mode as query-root-only auth in GraphQL.

Key Takeaways

The bypass primitive is consistent across every instance of this bug class: authorization is enforced at the operation entry point but not at every resolver that handles sensitive data. The field-level check is the minimal viable fix. The directive or middleware approach (graphql-shield, schema directives) scales better than per-resolver if/throw blocks as the schema grows, because it makes authorization visible in the schema definition rather than scattered across resolver implementations.

The hardest part of reviewing GraphQL for this pattern is tracing every path that can reach a sensitive type — aliases, fragments, and inline fragments all create paths that bypass a naive "is this field name protected?" check. Ownership checks tied to parent.id vs context.user.id inside the resolver itself are the only reliable guard.

If you want structured practice finding this in realistic code, practice spotting this in interview-style reviews on Code Review Lab or work through the full GraphQL security lab against a live vulnerable target — reading the pattern once and hunting it under time pressure are different skills.

Further Reading

Top comments (1)

Collapse
 
circuit profile image
Rahul S

This gets even nastier in Apollo Federation. When subgraph A owns User and subgraph B extends it via @key(fields: "id") to add something like creditHistory, the gateway's query plan calls B's reference resolver directly with just { __typename, id } — A's resolver tree and its auth checks never execute for that path. So B's resolver inherits zero authorization context from A unless it independently re-validates ownership against its own data layer. The graphql-shield approach breaks down here too, since shield rules are per-subgraph with no cross-subgraph composition. Router-level auth policies (Apollo Router's @policy directive) are really the only enforcement point that sees the full federated query plan, but most teams don't adopt those until after they've already shipped the per-subgraph pattern you're describing here.