Singleton Roulette: Racing for Context in GraphQL Modules
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:
- Update Immediately: Upgrade
graphql-modulesto 2.4.1 (if on v2) or 3.1.1 (if on v3). - Architectural Change: If you cannot upgrade, stop using
@ExecutionContext()insideScope.Singletonservices. Change the scope toScope.Operation(which creates a new instance per request) orScope.Request. - Manual Context Passing: The old-school way is the safe way. Pass the
contextobject 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.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
graphql-modules The Guild | >= 2.2.1 < 2.4.1 | 2.4.1 |
graphql-modules The Guild | >= 3.0.0 < 3.1.1 | 3.1.1 |
| Attribute | Detail |
|---|---|
| CWE | CWE-362 (Race Condition) |
| CVSS v4.0 | 8.7 (High) |
| Attack Vector | Network |
| Attack Complexity | Low |
| Privileges Required | None |
| User Interaction | None |
MITRE ATT&CK Mapping
Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.