# GraphQL Modules has a Race Condition issue

Singleton-scoped provider in graphql-modules leaks @ExecutionContext across concurrent requests, mixing auth tokens between users.

- Payout: Research
- Severity: high
- Program: graphql-modules
- Found by: DuckThom
- Disclosed: 2026-01-16
- CVE: CVE-2026-23735
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-53wg-r69p-v3r7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-53wg-r69p-v3r7
- Tags: area:web domain:api impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:graphql tech:nodejs technique:race-window vuln:access-control vuln:race-condition

**Target:** GraphQL server library singleton providers with @ExecutionContext injection
**Root cause:** Singleton-scoped providers store the injected ExecutionContext on a shared instance field, so concurrent async executions overwrite each other's context.
**Steps:**
1. Install graphql-modules 2.4.0 with babel decorators and parameter-decorator plugins.
2. Define `@Injectable({ scope: Scope.Singleton })` class with `@ExecutionContext() context` field and `getId()` returning `this.context.identifier`.
3. Resolver calls `context.injector.get(IdentifierProvider).getId()` before and after an awaited deferred promise.
4. Start first execution with `contextValue: { identifier: 'first' }`, await its 'before' read.
5. Start second execution with `contextValue: { identifier: 'second' }`, then resolve the deferred.
6. Observe first request's 'after' value returns 'second' instead of 'first'.
**How found:** Reporter noticed context mixing when parallel requests hit the same service; elevated original issue #2613 to security because ExecutionContext commonly carries auth tokens.
**Exploitation:** Any app passing per-request auth tokens via contextValue to singleton services using @ExecutionContext() can have one user's token served to another user's request under concurrency, enabling cross-tenant data access or auth confusion.
**Insight:** Singleton-scoped DI providers that cache per-request state (context, user, token) on instance fields are a race-condition class in any GraphQL/DI framework; test with interleaved deferred promises across two executions.

## Proof of concept (copied exactly from the original report)

**Jest test proving singleton context race condition**

```
require("reflect-metadata");
const {
  createApplication,
  createModule,
  Injectable,
  Scope,
  ExecutionContext,
  gql,
  testkit,
} = require("graphql-modules");

test("accessing a singleton provider context during another asynchronous execution", async () => {
  @Injectable({ scope: Scope.Singleton })
  class IdentifierProvider {
    @ExecutionContext()
    context;

    getId() {
      return this.context.identifier;
    }
  }

  const { promise: gettingBefore, resolve: gotBefore } = createDeferred();

  const { promise: waitForGettingAfter, resolve: getAfter } = createDeferred();

  const mod = createModule({
    id: "mod",
    providers: [IdentifierProvider],
    typeDefs: gql`
      type Query {
        getAsyncIdentifiers: Identifiers!
      }

      type Identifiers {
        before: String!
        after: String!
      }
    `,
    resolvers: {
      Query: {
        async getAsyncIdentifiers(_0, _1, context) {
          const before = context.injector.get(IdentifierProvider).getId();
          gotBefore();
          await waitForGettingAfter;
          const after = context.injector.get(IdentifierProvider).getId();
          return { before, after };
        },
      },
    },
  });

  const app = createApplication({
    modules: [mod],
  });

  const document = gql`
    {
      getAsyncIdentifiers {
        before
        after
      }
    }
  `;

  const firstResult$ = testkit.execute(app, {
    contextValue: {
      identifier: "first",
    },
    document,
  });

  await gettingBefore;

  const secondResult$ = testkit.execute(app, {
    contextValue: {
      identifier: "second",
    },
    document,
  });

  getAfter();

  await expect(firstResult$).resolves.toEqual({
    data: {
      getAsyncIdentifiers: {
        before: "first",
        after: "first",
      },
    },
  });

  await expect(secondResult$).resolves.toEqual({
    data: {
      getAsyncIdentifiers: {
        before: "second",
        after: "second",
      },
    },
  });
});

function createDeferred() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return {
    promise,
    resolve,
    reject,
  };
}
```

