From test schema exposure, resolver authorization, BOLA, injection, query abuse, CSRF, and subscriptions with short requests to expected outcomes and practical remediation guidance.
This practical GraphQL security testing checklist covers schema exposure, authentication, resolver and field authorization, broken object-level authorization (BOLA), injection, query abuse, cross-site request forgery (CSRF), subscriptions, and production hardening. It follows the path a tester would take – discovering the service, mapping the schema, establishing identity, testing access control, probing resolver inputs, assessing resource limits, and verifying production controls – and provides short representative requests, expected outcomes, and remediation guidance throughout.
Use these tests only on systems you own or have explicit authorization to assess, and run any resource-consumption checks in a controlled environment with monitoring and strict limits.

GraphQL security testing is the systematic assessment of a GraphQL API's schema exposure, authentication, resolver and field authorization, object access, input handling, query cost, batching, browser security, and subscription behavior.
The steps below can be used in a few different ways for different purposes:
The practical severity of findings should depend on the outcome and context, not the test technique alone:
A REST API usually exposes multiple routes tied to specific resources or operations – but a GraphQL API often concentrates many queries, mutations, and subscriptions behind a small number of endpoints.
GraphQL coverage therefore depends on far more than URLs. Testers need to account for:
While GraphQL's type system validates whether a request matches the schema, it does not automatically provide authentication, authorization, semantic input validation, safe database access, rate limiting, query limits, or secure error handling.
To get the best results and be able to use all the techniques listed here, set up at least:
Having two same-role users is essential for reliable BOLA testing.
Additional tips and tricks for setting up:
The following tests are organized in the order a practitioner would typically follow during an assessment – from endpoint discovery and schema analysis through authentication, authorization, injection, denial-of-service controls, browser security, subscriptions, and production hardening. Each section specifies a representative request, what to look for, the expected secure outcome, and where automation can help.
Common paths include:
/graphql
/api/graphql
/graphql/api
/graphql/v1
/query
/gqlAlso search JavaScript bundles, mobile traffic, documentation, gateway configuration, and proxy history for fields such as query, mutation, variables, and operationName.
Representative test:
query {
__typename
}A GraphQL-aware response might be:
{
"data": {
"__typename": "Query"
}
}A syntax or validation error can also confirm that the endpoint parses GraphQL.
Check for alternate endpoints, unexpected methods and content types, and exposed development interfaces such as GraphiQL, Playground, Apollo Explorer, Altair, or Voyager.
Typical severity: Low to Medium, but higher when an alternate endpoint bypasses controls.
Useful manual tools: InQL, Burp Suite, OWASP ZAP, GraphiQL, Altair, and graphql-cop.
Automated testing: Invicti can detect GraphQL endpoints, identify over a dozen supported GraphQL libraries, and detect exposed GraphiQL Explorer and Playground interfaces.
Introspection is a legitimate GraphQL capability, not automatically a vulnerability. The question is whether it should be available to a specific user in a specific environment.
Representative introspection test:
query {
__schema {
queryType {
name
}
mutationType {
name
}
}
}To test a specific type:
query {
__type(name: "User") {
fields {
name
description
isDeprecated
deprecationReason
}
}
}Review not only names but also descriptions, deprecated fields, custom directives, administrative operations, and sensitive arguments.
Also test field suggestions using a misspelled field:
query {
usr {
id
}
}A revealing response might suggest user or users, helping reconstruct the schema even when introspection is disabled.
Vulnerable result: Unauthenticated users can retrieve sensitive schema details, administrative operation names, or useful implementation metadata.
Expected result: Exposure follows a deliberate production policy, metadata contains no sensitive details, and protected fields remain inaccessible even when their names are known.
Typical severity: Medium for disclosure alone.
Useful manual tools: GraphiQL, Altair, GraphQL Voyager, InQL, Clairvoyance, and graphql-cop.
Automated testing: Invicti detects introspection, field suggestions, and auto-correct behavior.
Authentication only establishes who the requester is, but it does not establish what that identity may access (authorization).
Representative query:
query {
currentUser {
id
email
}
}Repeat the query after removing the credential, replacing it with an invalid or expired token, using another user's token, moving the token between cookie and header, or changing the transport.
Test a harmless mutation, for example:
mutation {
updateProfile(input: { displayName: "Test" }) {
id
}
}Where JSON Web Tokens (JWTs) are used, verify JWT security:
alg: noneNote that weak HMAC-secret testing is relevant only where symmetric JWT signing is used. Do not treat a decoded or modified token as an exploit unless the server accepts it.
Vulnerable result: Protected queries or mutations execute with missing, invalid, expired, unsigned, or incorrectly scoped credentials.
Expected result: Protected operations require valid authentication consistently across endpoints and transports. Long-lived connections do not preserve invalid access indefinitely.
Typical severity: High; Critical when the issue provides broad account or administrative access.
Automated testing: Invicti detects unauthenticated GraphQL mutations.
GraphQL authorization can fail at the operation, object, resolver, field, relationship, or event level.
Consider a query like:
query {
user(id: "1001") {
id
name
paymentMethods {
lastFour
billingAddress
}
}
}The top-level user resolver may enforce access while the nested paymentMethods resolver does not.
To test this, start with an allowed query:
query {
user(id: "1001") {
id
name
}
}Add fields the expected client does not request:
query {
user(id: "1001") {
id
name
email
role
internalNotes
}
}Then test nested relationships, fragments, aliases, interfaces, unions, deprecated fields, and alternate query shapes.
Vulnerable result: A user can retrieve protected fields or nested objects through a resolver path that lacks the expected check.
Expected result: Every protected object and field checks authorization in the relevant context. Hiding a field in the frontend is never treated as a security control.
Typical severity: High; Critical when access exposes highly sensitive data or privileged actions.
Broken object-level authorization (BOLA) occurs when a user can access another user's object by changing an identifier. GraphQL can expose the same object through direct queries, nested relationships, global IDs, mutations, cursors, or subscriptions, so all these need to be tested.
Representative test
Start with a sample query using account A:
query {
invoice(id: "INV-A-100") {
id
total
status
}
}Then replace the identifier with an object owned by account B:
query {
invoice(id: "INV-B-200") {
id
total
status
}
}Where present, test generic node interfaces:
query {
node(id: "SW52b2ljZToxMDA=") {
id
... on Invoice {
total
}
}
}Test read, update, delete, export, share, approve, and subscription access.
BOLA testing result matrix
To determine the result, do not rely only on the HTTP status – inspect both data and errors.
Vulnerable result: User A receives or modifies an object owned by user B or another tenant.
Expected result: Access is denied without revealing whether the object exists.
Typical severity: High; Critical for sensitive cross-tenant exposure, unauthorized modification, or destructive actions.
For an in-depth look at BOLA testing, see our guide to detecting BOLA in APIs.
BOLA asks whether a user may act on a specific object, while broken function-level authorization (BFLA) asks whether the user may invoke a specific operation.
Representative test:
mutation {
suspendUser(id: "1002") {
id
status
}
}Test the mutation as an unauthenticated user, standard user, support user, tenant administrator, and global administrator.
Vulnerable result: A low-privilege user invokes an administrative mutation or equivalent privileged operation.
Expected result: The operation is denied before sensitive resolver logic runs.
Typical severity: High; Critical when the issue enables administrative compromise or privilege escalation.
For more on BFLA detection, see our BFLA testing guide.
Mutations combine function access, object access, workflow state, and client-controlled input.
Representative test:
mutation {
updateUser(
id: "1001"
input: { displayName: "Alex" }
) {
id
displayName
}
}Add fields that should be server-controlled:
mutation {
updateUser(
id: "1001"
input: {
displayName: "Alex"
role: "ADMIN"
accountStatus: "VERIFIED"
}
) {
id
role
accountStatus
}
}Also replace the object ID and test whether the workflow can be skipped.
Vulnerable result: A user changes ownership, role, tenant, verification, approval, or workflow state without the required permission.
Expected result: The mutation authorizes both the function and object, accepts only intended fields, and enforces state transitions on the server.
Typical severity: High; Critical when privilege, ownership, or protected account state can be changed.
GraphQL schema validation does not prevent a resolver from using input unsafely in SQL, NoSQL, commands, templates, paths, LDAP, or outbound requests.
Query argument:
query {
product(search: "test'") {
id
name
}
}Mutation argument:
mutation {
createTicket(
input: {
title: "Test"
reference: "1001'"
}
) {
id
}
}Nested input:
mutation {
createReport(
input: {
filters: {
customerId: "1001'"
}
}
) {
id
}
}Test query arguments, mutation arguments, nested inputs, IDs, custom scalars, URLs, filters, paths, and client-controlled directive arguments.
Depending on the resolver, consider SQL injection, NoSQL injection, command injection, SSRF, path traversal, LDAP injection, template injection, header injection, XML or XPath injection, and regular-expression denial of service.
Expected result: Inputs receive semantic validation, database access uses safe parameterized APIs, outbound destinations are constrained, and backend errors are not exposed.
Typical severity: High; Critical when the issue enables broad data access, remote code execution, or infrastructure compromise.
Useful manual testing tools: Burp Suite, InQL, OWASP ZAP, and controlled out-of-band interaction services.
Automated testing: Invicti tests query and mutation arguments for injection, including nested inputs, custom scalars, and ID types.
Some servers accept an array of operations in one HTTP request. The following is a raw JSON HTTP request body, not a GraphQL document to paste directly into a GraphiQL query pane:
[
{
"query": "query { account(id: \"1001\") { id } }"
},
{
"query": "query { account(id: \"1002\") { id } }"
}
]Test maximum operations per batch, mixed queries and mutations, partial failures, body size, expensive grouped operations, and rate-limit accounting per logical operation.
Vulnerable result: One HTTP request performs many sensitive actions, bypasses rate limits, or creates disproportionate workload.
Expected result: Batching is disabled when unnecessary or constrained by operation count, cost, size, execution time, identity, and authorization.
Typical severity: Medium when batching is enabled without demonstrated impact; High when it bypasses security or resource controls.
Automated testing: Invicti detects array-based GraphQL query batching.
Aliases let a client repeat fields or mutations under different result names.
query {
a: product(id: "1") { name }
b: product(id: "2") { name }
c: product(id: "3") { name }
}A mutation can also be repeated:
mutation {
a: verifyCode(code: "000001") { success }
b: verifyCode(code: "000002") { success }
c: verifyCode(code: "000003") { success }
}Test whether aliases multiply resolver work or bypass anti-automation and rate limits.
Vulnerable result: Repeated aliases trigger many sensitive actions while counting as one request.
Expected result: Alias count, field breadth, operation cost, and sensitive actions are limited and counted appropriately.
Typical severity: Medium without demonstrated impact; High when a practical bypass or DoS condition is reproduced.
Automated testing: Invicti detects alias overloading DoS conditions.
A deeply nested query can repeatedly traverse object relationships:
query {
user(id: "1001") {
manager {
manager {
manager {
id
}
}
}
}
}Circular relationship traversal can be especially expensive:
query {
user(id: "1001") {
team {
members {
team {
members {
id
}
}
}
}
}
}This test follows cyclic relationships in the schema, such as user → team → members → team. It is different from a circular fragment definition, which a conforming GraphQL validator should reject before execution.
A shallow query can still be expensive if it requests a wide selection set or repeats the same resolver through aliases. In this simplified example, the nesting depth stays low, but each alias can trigger additional work. In a real query, that cost can increase further when many fields are requested across large result sets:
query {
p1: product(id: "1") { name }
p2: product(id: "2") { name }
p3: product(id: "3") { name }
}Test pagination and result limits:
query {
users(first: 1000) {
nodes {
id
}
}
}Overall, assess:
Expected result: The server applies layered limits that reflect real resolver behavior rather than relying on one generic threshold.
Typical severity: Medium for missing limits; High when practical service degradation is demonstrated.
Useful tools: graphql-cop can help identify common GraphQL configuration and demand-control weaknesses. A proxy or GraphQL client is still needed for controlled depth, breadth, and cost testing.
Automated testing: Invicti detects circular-query DoS conditions and unchecked query length.
Cookie-authenticated GraphQL endpoints can be exposed to cross-site request forgery (CSRF) if they accept cross-origin requests that browsers can send without a CORS preflight, such as form-encoded or plain-text POST requests. Requiring application/json for state-changing POST requests reduces exposure to browser-simple CSRF requests, but it's not sufficient if the server also allows state-changing operations through GET or accepts other simple content types.
SameSite cookies provide another layer of protection. Use SameSite=Strict for session cookies where application flows permit it, or Lax where cross-site top-level navigation is required. Test actual browser behavior rather than relying on the attribute alone, especially when SameSite is omitted and the browser applies Lax handling by default.
Representative mutation:
mutation {
updateEmail(input: { email: "new@example.com" }) {
success
}
}Equivalent form-encoded request:
Content-Type: application/x-www-form-urlencoded
query=mutation+%7B+updateEmail(input%3A+%7Bemail%3A+%22new%40example.com%22%7D)+%7Bsuccess%7D+%7DCheck cookie authentication, accepted content types, GET behavior, SameSite settings, CSRF tokens, custom headers, Origin and Referer validation, and CORS.
Read-only queries over GET are not automatically a CSRF vulnerability, but queries with side effects, sensitive cached responses, or unsafe resolver behavior still require review.
Vulnerable result: A cross-origin browser request performs a meaningful mutation using the victim's authenticated session.
Expected result: State-changing operations require an explicit CSRF defense, unnecessary content types are rejected, and mutations cannot execute through GET.
Typical severity: High when a meaningful state-changing action is possible.
Automated testing: Invicti detects CSRF exposure through non-JSON GraphQL queries and mutations.
Submit syntax errors, type mismatches, nonexistent IDs, and controlled resolver failures, for example:
query {
user(
}query {
user(id: true) {
id
}
}Review responses for stack traces, source paths, framework versions, database errors, internal hostnames, resolver names, backend statements, secrets, tokens, and object-existence clues.
Expected result: Clients receive stable, sanitized errors while detailed diagnostics remain in protected logs.
Typical severity: Medium; higher when errors expose secrets, sensitive data, or a direct exploit path.
Automated testing: Invicti detects GraphQL error leakage and stack-trace disclosure.
A GraphQL upload may look like:
mutation UploadAvatar($file: Upload!) {
uploadAvatar(file: $file) {
filename
}
}Test file size, count, actual content type, filename handling, storage location, malware scanning, parser limits, cleanup, ownership, retrieval authorization, and CSRF protection.
Multipart upload formatting is implementation-specific, so the full request should follow the server's documented upload convention.
Testing custom scalars
A URL scalar might pass data to a server-side fetcher:
mutation {
importFromUrl(url: "http://127.0.0.1:8080/admin") {
status
}
}A JSON scalar may allow unexpected nested fields:
mutation {
updateSettings(
input: {
config: {
role: "admin"
}
}
) {
success
}
}Review custom scalars such as URL, JSON, UUID, Upload, HTML, or domain-specific IDs. A scalar name does not guarantee strict validation.
Testing directives
Standard directives such as @include and @skip normally affect field selection:
query {
account(id: "1001") @include(if: true) {
id
balance
}
}Custom directives may influence authorization, caching, transformation, or resolver execution. Test whether:
There is no universal directive-bypass payload – testing needs to follow the directives implemented by the target schema.
Expected result: Uploads, custom scalars, and directives receive explicit validation and authorization appropriate to how the resolver uses them.
Typical severity: Medium; High when the behavior enables unauthorized access, SSRF, unsafe file handling, or another exploitable condition.
GraphQL subscriptions often use long-lived WebSocket connections with separate connection, subscription, and event-delivery stages.
Start with a legitimate object-scoped subscription, then replace the identifier with one belonging to another controlled user or tenant to test whether subscription and event delivery authorization are enforced:
subscription {
paymentUpdated(accountId: "ACC-1001") {
id
status
}
}Cross-site WebSocket hijacking can occur when a browser opens an authenticated WebSocket connection from an untrusted origin, typically because the upgrade request uses ambient cookies and the server does not validate the Origin header.
What to test for:
Vulnerable result: A user receives another user's events, retains access after permission changes, or opens an authenticated socket from an untrusted origin.
Expected result: The server authenticates the connection, authorizes every subscription and event, enforces origin policy, and responds to permission changes during long-lived sessions.
Typical severity: High; Critical for sensitive cross-user or cross-tenant event exposure.
Useful tools: Use a WebSocket-capable interception proxy to inspect connection setup, subscription messages, token handling, and event delivery.
Persisted queries and trusted documents reduce the range of accepted operations but do not replace authorization or demand controls.
Start with a known persisted operation, then change the hash, remove the document, or supply a full query alongside an unknown hash to verify whether the server truly enforces the allowlist and handles fallback behavior safely:
{
"operationName": "GetInvoice",
"variables": {
"id": "INV-A-100"
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "example-hash"
}
}
}Vulnerable result: Arbitrary or deprecated operations execute despite claimed allowlisting.
Expected result: Only approved operations execute where allowlisting is required, while runtime authorization and variable validation remain in place.
Typical severity: Medium; High when bypass exposes restricted functionality or weakens demand controls.
For logging, do not rely on operationName alone. It is client-supplied and optional, so unnamed operations can create blind spots. Use a normalized document hash, persisted-operation ID, or another stable server-side identifier alongside any supplied operation name.
Manual testing is essential for business rules, role comparisons, multi-step workflows, BOLA, and subscription context. Automated dynamic application security testing (DAST) complements that work by repeatedly exercising running GraphQL APIs for vulnerabilities and insecure behavior.
Invicti's API DAST can scan GraphQL APIs using an imported schema or introspection-based discovery, then exercise supported operations as part of authenticated or recurring DAST scans. This automates repeatable checks while leaving business-context authorization decisions to manual testing.
The right balance of manual and automated testing depends on how much application context each test requires. Automated scanning is well suited to repeatable technical checks, while authorization and workflow testing often require controlled users, roles, tenants, and known object relationships.
Tips for testing in CI/CD pipelines:

A GraphQL assessment should produce more than isolated findings. Convert confirmed issues into authorization regression tests, secure resolver patterns, reusable DAST checks, updated inventories, monitoring rules, and retest requirements.
The strongest programs combine manual context with repeatable runtime testing. Manual testers establish what each user should be allowed to do, while automated testing verifies that common weaknesses and regressions do not return as the schema and resolver layer change.
It should cover endpoint discovery, schema exposure, authentication, resolver and field authorization, BOLA, privileged operations, mutation security, injection, batching, aliases, query depth, complexity, CSRF, errors, uploads, subscriptions, persisted queries, and production monitoring.
One HTTP request can contain multiple logical operations through batching or aliases. A gateway that counts only HTTP requests may underestimate authentication attempts, mutations, or backend workload. Rate limits should account for identity, operation count, and total query cost.
DAST can automate repeatable runtime checks such as introspection, field suggestions, batching, injection, query limits, CSRF, error leakage, and exposed development tooling. Manual testing remains necessary for business logic, multi-user authorization, BOLA, and subscription context.
