CVE-2026-23735

Singleton Roulette: Racing for Context in GraphQL Modules

Alon Barad
Alon Barad
Software Engineer

Jan 17, 2026·7 min read

Executive Summary (TL;DR)

Using `@ExecutionContext()` inside a Singleton service in `graphql-modules` creates a shared mutable state. In a concurrent environment (like any real-world API), this leads to a classic race condition where parallel requests overwrite each other's context data. The result is severe identity confusion and data leakage. The fix involves upgrading to version 2.4.1 or 3.1.1, or avoiding context injection in singletons.

A critical race condition in the popular `graphql-modules` library allows request contexts to cross-pollinate when using the `@ExecutionContext` decorator within Singleton-scoped services. This effectively allows User A to unintentionally inherit the session, authentication tokens, or data of User B if their requests are processed concurrently.

The Hook: Dependency Injection Gone Wrong

Dependency Injection (DI) is supposed to make our lives easier. You define a service, slap a @Injectable decorator on it, and let the framework handle the plumbing. In the world of GraphQL, accessing the context (which usually holds the holy grail of req, res, and authToken) is something you do in almost every resolver. The graphql-modules library provided a syntactic sugar so sweet it gave us cavities: the @ExecutionContext() decorator.

Here is the pitch: instead of passing the context object down through twenty layers of function arguments like a bucket brigade from hell, you just decorate a class property, and voilà! The context is available right there on this.context. It looks clean. It looks professional. It looks safe.

But here is the catch. Developers love optimization. And what is the most common optimization in DI? Singleton scope. Why create a new instance of AuthService for every single request when you can just have one global instance? It saves memory! It saves CPU cycles! Unfortunately, in this specific case, it also saves you the trouble of keeping your users' data private. By combining @ExecutionContext() with Scope.Singleton, developers inadvertently turned their stateless API into a giant game of musical chairs where the music stops every time an await keyword is hit.

The Flaw: The Global Variable Fallacy

To understand this bug, we need to revisit Computer Science 101: Scope and Memory. A Singleton in a Node.js application is effectively a global variable. It is instantiated once when the application starts, and that same object instance is reused for every incoming request. If you store state on that object, that state is shared across all requests.

When graphql-modules injects the context using the @ExecutionContext() decorator, it is essentially doing this.context = currentRequest.context under the hood. If the service is a Singleton, this refers to the same object for everyone. Now, imagine Node.js's event loop. It is single-threaded, but it is concurrent. When Request A enters the function and hits an await database.query(), it yields control back to the event loop.

While Request A is sitting there twiddling its thumbs waiting for the database, Request B comes in. Request B calls the same method on the same Singleton instance. The decorator runs and sets this.context to Request B's context. The Singleton, being a dumb object, happily overwrites the reference. Moments later, the database returns data for Request A. Request A resumes execution and reaches for this.context. Surprise! It is now holding Request B's context. Request A has just become Request B.

The Code: Anatomy of a Race Condition

Let's look at the "Smoking Gun." The code below looks perfectly reasonable to a developer who trusts their framework, but it contains a critical architectural flaw. The combination of Scope.Singleton and @ExecutionContext() is the trigger.

const { Injectable, Scope, ExecutionContext } = require("graphql-modules");
 
@Injectable({
  scope: Scope.Singleton // <--- The root of all evil
})
class UserProfileService {
  
  @ExecutionContext() // <--- The mechanism of failure
  context;
 
  async getCurrentUser() {
    // 1. We access the context to get the auth token
    const token = this.context.headers.authorization;
    
    // 2. We perform an async operation (DB call, external API, etc.)
    // This pauses execution and yields the event loop.
    const userData = await this.db.findUserByToken(token);
 
    // 3. DANGER ZONE: If we access this.context again here,
    // it might have changed while we were awaiting above!
    console.log(`Processed request for ${this.context.userId}`); 
    
    return userData;
  }
}

In the patched versions (2.4.1+ and 3.1.1+), the graphql-modules team moved away from simple property assignment. They likely implemented a mechanism similar to Node.js's AsyncLocalStorage (or relied on a closure-based approach for non-singletons), ensuring that the context is bound to the execution flow (the "fiber" of the request) rather than the properties of a long-lived object instance.

The Exploit: Stealing Identity via Timing

Exploiting this does not require fancy shellcode or heap spraying. It just requires timing. We need to send two requests: a "Victim" request that takes a while to process (hitting a slow database query or external API), and an "Attacker" request that fires immediately after.

Here is the attack flow visualised:

The PoC: To reproduce this, you create a resolver that deliberately sleeps for 500ms. Send Request A. 10ms later, send Request B. If the vulnerability is present, Request A will return the data associated with Request B's context. In a real-world scenario, this means if I browse my profile while an admin is browsing the admin panel, I might suddenly get the admin's session token or view the data they were querying. It is catastrophic for multi-tenant applications.

The Impact: Cross-Tenant Data Leakage

This is a High (8.7) severity vulnerability for a reason. While it does not directly offer Remote Code Execution (RCE), the data confidentiality impact is total. In modern GraphQL architectures, the context object is the keychain. It holds the Authorization header, the decoded JWT, the userId, and often database connectors specific to a tenant.

If you are running a SaaS platform where Tenant A and Tenant B share the same backend, this bug dissolves the walls between them. If ExecutionContext is used to determine which database schema to query, User A could inadvertently query User B's database. If it is used for logging, your audit logs become corrupted with mismatched user actions.

The randomness makes it worse. It is a Heisenbug. It only happens under load. You might pass all your unit tests (because unit tests usually run sequentially) and then leak massive amounts of PII the moment you go into production on Black Friday.

The Fix: AsyncLocalStorage to the Rescue

The remediation is straightforward but mandates an update. The library maintainers patched this by fundamentally changing how context is stored. Instead of relying on mutable object properties on the Singleton, the fix utilizes mechanisms like AsyncLocalStorage (ALS) from Node.js core.

Mitigation Strategies:

  1. Update Immediately: Upgrade graphql-modules to 2.4.1 (if on v2) or 3.1.1 (if on v3).
  2. Architectural Change: If you cannot upgrade, stop using @ExecutionContext() inside Scope.Singleton services. Change the scope to Scope.Operation (which creates a new instance per request) or Scope.Request.
  3. Manual Context Passing: The old-school way is the safe way. Pass the context object as an argument to your service methods instead of injecting it. It is more verbose, but it is thread-safe by design.
// SAFE: Pass context explicitly
class UserProfileService {
  async getCurrentUser(context: GraphQLModules.Context) {
    return context.userId;
  }
}

This vulnerability serves as a grim reminder: syntactic sugar is sweet, but thread-safety (or concurrency-safety in Node) is the meat and potatoes. Always know where your state lives.

Fix Analysis (1)

Technical Appendix

CVSS Score
8.7/ 10
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
EPSS Probability
0.04%
Top 100% most exploited

Affected Systems

GraphQL Modules (npm package: graphql-modules)

Affected Versions Detail

Product
Affected Versions
Fixed Version
graphql-modules
The Guild
>= 2.2.1 < 2.4.12.4.1
graphql-modules
The Guild
>= 3.0.0 < 3.1.13.1.1
AttributeDetail
CWECWE-362 (Race Condition)
CVSS v4.08.7 (High)
Attack VectorNetwork
Attack ComplexityLow
Privileges RequiredNone
User InteractionNone
CWE-362
Race Condition

Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')

Vulnerability Timeline

Issue Reported
2025-04-28
Patch Developed
2026-01-15
Public Disclosure (GHSA)
2026-01-16

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.