The Relay Race to Nowhere: Bypassing Auth in API Platform's GraphQL Node Interface
Jan 13, 2026·5 min read
Executive Summary (TL;DR)
API Platform's implementation of the GraphQL Relay `node` field failed to load resource-specific security metadata. By querying a sensitive resource via `node(id: "/iri")`, attackers could bypass `security` attributes (like `ROLE_ADMIN`) and read data they shouldn't see. The fix involves forcing a reverse lookup of the operation metadata based on the requested IRI.
A logic error in API Platform's GraphQL Relay implementation allows attackers to bypass security rules by querying resources via the global `node` interface, effectively ignoring configured access controls.
The Hook: The Skeleton Key in the Graph
In the world of GraphQL, the Relay specification is like the high-society club of API design. It mandates strict standards for global object identification, pagination, and consistency. One of its crown jewels is the node interface. It’s a magical, polymorphic entry point that says, "Give me an ID, and I will give you the object, no matter what type it is."
From a developer's perspective, this is pure convenience. You don't need to know if ID 123 is a User, a Post, or a NuclearLaunchCode. You just query node(id: "...") and the server figures it out. It's the ultimate skeleton key for your data graph.
But here is the problem with skeleton keys: if you forget to check who is holding them, they open doors for the wrong people. In CVE-2025-31481, API Platform—one of the most popular PHP frameworks for building API-driven projects—made a classic mistake. They implemented the mechanics of the key perfectly but forgot to check the lock's security instructions when that specific key was used.
The Flaw: Identity Crisis in the Resolver
To understand this bug, you have to understand how API Platform secures its endpoints. Typically, you define a resource (let's say SecuredDummy) and attach security attributes to its operations. You might say: "To read this, the user must have ROLE_ADMIN."
When you hit the standard endpoint (e.g., query { securedDummy(id: "...") }), API Platform looks up the metadata for that operation, sees the security rule, and enforces it. Simple.
However, when you use the global node query, the request hits a specialized ResolverFactory. The resolver needs an Operation object to know what rules to apply. In vulnerable versions, if the resolver couldn't immediately determine the operation context (which happens with the generic node field), it shrugged its shoulders and instantiated a generic, blank Query object.
[!WARNING] The Fatal Flaw: A blank
Queryobject has no security rules attached to it. It is a ghost operation.
Because the security system works by checking constraints on the current operation, it looked at this ghost operation, saw zero constraints, and effectively said, "Go right ahead." The data loader then fetched the sensitive resource using the ID provided, completely bypassing the Access Control List (ACL) meant for that specific resource.
The Smoking Gun: Defaulting to Insecurity
Let's look at the code. The vulnerability lived in src/GraphQl/Resolver/Factory/ResolverFactory.php. This class is responsible for creating the logic that fetches data.
Here is the logic pre-patch. Notice how it handles the absence of an operation:
// Vulnerable Code (simplified)
private function resolve($source, $args, $context, ...)
{
// ... initialization logic ...
// If no specific operation is defined (common in 'node' queries),
// just make up a blank one.
$operation ??= new Query();
// Proceed to execute the query with the blank operation...
// Security listeners check $operation, find nothing, and pass.
}The fix was to stop being lazy. Instead of defaulting to new Query(), the patch forces the system to look up the actual operation associated with the requested ID (IRI). They introduced a RuntimeOperationMetadataFactory to reverse-engineer the request:
// Patched Code (Commit 60747cc8c2fb855798c923b5537888f8d0969568)
if (!$operation) {
if (!isset($args['id'])) {
throw new NotFoundHttpException('No node found.');
}
// CRITICAL FIX: Determine the real operation from the ID
$operation = $this->operationMetadataFactory->create($args['id']);
}By adding this lookup, the resolver now finds the original SecuredDummy configuration, sees the is_granted('ROLE_ADMIN') rule, and correctly denies access.
The Exploit: Walking Through the Side Door
Exploiting this is trivially easy if you know the IRI (Internationalized Resource Identifier) of the target resource. In API Platform, IRIs usually look like /resource_name/id.
Let's assume there is a resource called Salaries which is strictly restricted to HR administrators.
Attempt 1: The Front Door (Blocked) Standard GraphQL query:
query {
salary(id: "/salaries/1") {
amount
employee
}
}Result: 403 Forbidden (The salary query operation correctly enforces ROLE_HR).
Attempt 2: The Side Door (Vulnerable)
Using the Relay node interface:
query {
node(id: "/salaries/1") {
... on Salary {
amount
employee
}
}
}Result: 200 OK. The node resolver uses the blank Query object. The security check passes (because there are no rules to check), and the underlying data provider fetches the row from the database. The attacker gets the JSON payload containing the sensitive salary data.
The Impact: Read-Only, but Deadly
This is primarily a Confidentiality breach. The CVSS score is 7.5 (High), which is appropriate. It allows unauthenticated or low-privilege users to read data they are explicitly forbidden from seeing.
While this doesn't directly allow for Write/Delete operations (Mutations in GraphQL are handled differently and usually don't go through the node query resolver in this specific way), the ability to exfiltrate an entire database is often arguably worse than defacing it.
Consider an application hosting medical records or financial data. An attacker can write a script to iterate through IDs (e.g., /patients/1, /patients/2) using the node query and dump the entire dataset without ever triggering an authorization error.
The Fix: Reverse Engineering the Request
The mitigation is straightforward: Update. The patch introduces a clever mechanism that uses the Symfony Router to parse the incoming IRI inside the GraphQL resolver.
How it works now:
- Request comes in for
node(id: "/salaries/1"). - The
RuntimeOperationMetadataFactorytakes/salaries/1. - It matches this string against the registered routes to find the resource class (
App\Entity\Salary). - It retrieves the GraphQL operations defined for
Salary. - It selects the
getorqueryoperation metadata, which containssecurity: "is_granted('ROLE_HR')". - The security listener enforces this rule, blocking the request.
Developers using API Platform versions < 3.4.17 or >= 4.0.0 < 4.0.22 are vulnerable and must upgrade immediately.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
api-platform/core api-platform | < 3.4.17 | 3.4.17 |
api-platform/core api-platform | >= 4.0.0, < 4.0.22 | 4.0.22 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-863 (Incorrect Authorization) |
| CVSS v3.1 | 7.5 (High) |
| Attack Vector | Network (GraphQL API) |
| Impact | Confidentiality Loss (Data Exfiltration) |
| Exploit Status | Trivial / PoC Available |
| KEV Status | Not Listed |
MITRE ATT&CK Mapping
The software does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.