GraphQL Security Testing: Introspection Attacks, Injection, and Authorization

GraphQL Security Testing: Introspection Attacks, Injection, and Authorization

GraphQL's flexibility is a feature and a vulnerability surface at the same time. A single endpoint replaces dozens of REST routes, introspection hands attackers a full schema map, and deeply nested queries can bring down a server without a single line of malicious code. REST security checklists don't map cleanly onto GraphQL. You need a different testing approach.

This post covers the five most critical GraphQL security areas with test code that verifies defenses actually work — not just that attacks exist.


The GraphQL Attack Surface

Before writing tests, understand what makes GraphQL different from REST:

Single endpoint, full schema. POST /graphql handles everything. A misconfigured server hands attackers a complete API map via introspection.

Client-controlled queries. Unlike REST where the server controls response shape, GraphQL lets clients request exactly what they want. That includes fields they shouldn't have access to.

Nested queries as amplification. A single request can trigger hundreds of database calls through recursive relationships. user { friends { friends { friends { ... } } } } — this is a legitimate query structure that becomes a DoS vector.

Variables bypass naive sanitization. Query strings look clean. The injected payload is in the variables JSON object, which doesn't get sanitized by input filters watching the query body.

Errors are verbose by default. Stack traces, database errors, and schema details leak through GraphQL error responses in ways that REST APIs typically suppress.


Testing Introspection Controls

Introspection lets any client query the schema: every type, field, argument, and relationship. Useful in development. Dangerous in production — it's a free recon tool for attackers.

Test 1: Introspection Should Be Disabled in Production

import requests
import pytest

GRAPHQL_URL = "https://api.yourapp.com/graphql"

INTROSPECTION_QUERY = """
{
  __schema {
    queryType {
      name
    }
    types {
      name
      fields {
        name
      }
    }
  }
}
"""

def test_introspection_disabled_in_production():
    """Introspection must be blocked on the production endpoint."""
    response = requests.post(
        GRAPHQL_URL,
        json={"query": INTROSPECTION_QUERY},
        headers={"Content-Type": "application/json"}
    )
    data = response.json()

    # Either the request fails entirely, or data.__schema is null
    if "data" in data and data["data"] is not None:
        assert "__schema" not in data["data"], (
            "Introspection is enabled in production. "
            "Schema types exposed: "
            + str([t["name"] for t in data["data"]["__schema"]["types"][:5]])
        )

    # Alternatively, check for an explicit error
    if "errors" in data:
        error_messages = [e.get("message", "") for e in data["errors"]]
        assert any(
            "introspection" in msg.lower() or "not allowed" in msg.lower()
            for msg in error_messages
        ), f"Got errors but not an introspection block: {error_messages}"

Test 2: Type Name Probing (Introspection Lite)

Even with introspection disabled, __typename often works. Test for it — but more importantly, test that field enumeration via __type is blocked:

def test_typename_field_probing_blocked():
    """
    __type queries are a lighter introspection method.
    These should be blocked when introspection is disabled.
    """
    probe_query = """
    {
      __type(name: "User") {
        name
        fields {
          name
          type {
            name
          }
        }
      }
    }
    """
    response = requests.post(
        GRAPHQL_URL,
        json={"query": probe_query},
        headers={"Content-Type": "application/json"}
    )
    data = response.json()

    if "data" in data and data["data"] is not None:
        assert data["data"].get("__type") is None, (
            "Field-level type probing succeeded. Fields exposed: "
            + str(data["data"]["__type"])
        )

Test 3: Dev Endpoint Not Exposed

Development environments often enable introspection. Verify there's no accessible /graphql/dev, /api/graphql, or staging endpoint reachable from production networks:

@pytest.mark.parametrize("path", [
    "/graphql",
    "/api/graphql",
    "/v1/graphql",
    "/graphiql",
    "/playground",
])
def test_alternate_graphql_endpoints_closed(path):
    """Common alternate GraphQL endpoints should not be accessible."""
    response = requests.post(
        f"https://api.yourapp.com{path}",
        json={"query": "{ __typename }"},
        headers={"Content-Type": "application/json"},
        allow_redirects=False
    )
    assert response.status_code in [404, 403, 401], (
        f"Unexpected GraphQL endpoint at {path}: HTTP {response.status_code}"
    )

Field-Level Authorization Tests

GraphQL resolvers can have authorization at the query level or the field level. The common failure mode: the top-level query checks auth, but individual fields don't. An attacker authenticated as a low-privilege user requests fields meant for admins.

Test 4: Low-Privilege User Cannot Access Admin Fields

def get_auth_token(role: str) -> str:
    """Fixture — returns a JWT for the specified role."""
    tokens = {
        "user": "eyJ...<regular user token>...",
        "admin": "eyJ...<admin token>...",
    }
    return tokens[role]

def test_regular_user_cannot_access_admin_fields():
    """
    A regular user querying adminNotes or internalId should get
    null values or an authorization error — not the actual data.
    """
    query = """
    query GetUser($id: ID!) {
      user(id: $id) {
        id
        email
        adminNotes      # admin-only field
        internalScore   # admin-only field
        billingDetails {
          cardLast4
          stripeCustomerId  # should never be exposed to users
        }
      }
    }
    """
    response = requests.post(
        GRAPHQL_URL,
        json={"query": query, "variables": {"id": "user-123"}},
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {get_auth_token('user')}"
        }
    )
    data = response.json()

    # Fields should be null or absent, not populated
    if "data" in data and data["data"] and data["data"].get("user"):
        user = data["data"]["user"]
        assert user.get("adminNotes") is None, \
            f"adminNotes leaked to regular user: {user['adminNotes']}"
        assert user.get("internalScore") is None, \
            f"internalScore leaked to regular user: {user['internalScore']}"

        billing = user.get("billingDetails")
        if billing:
            assert billing.get("stripeCustomerId") is None, \
                f"stripeCustomerId leaked: {billing['stripeCustomerId']}"

Test 5: IDOR via GraphQL ID Arguments

def test_user_cannot_access_other_users_data():
    """
    User A's token should not be able to fetch User B's private data.
    This tests object-level authorization, not just field-level.
    """
    # Authenticate as user A, request user B's data
    other_user_id = "user-456"  # A different user's ID

    query = """
    query($id: ID!) {
      user(id: $id) {
        id
        email
        privateNotes
        paymentMethods {
          last4
          token
        }
      }
    }
    """
    response = requests.post(
        GRAPHQL_URL,
        json={"query": query, "variables": {"id": other_user_id}},
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {get_auth_token('user')}"
        }
    )
    data = response.json()

    # Should get null data or an authorization error
    if "data" in data and data["data"] and data["data"].get("user"):
        # If server returns data, it must be null for private fields
        user = data["data"]["user"]
        assert user.get("privateNotes") is None
        assert user.get("paymentMethods") in [None, []]
    else:
        # Errors array is also acceptable — just not actual data
        assert "errors" in data, \
            "Expected either null data or errors for cross-user access"

Injection via Variables (Testing Sanitization)

GraphQL variables are the injection vector most teams miss. The query string is static and might get sanitized. The variables object is JSON — it goes directly into resolver arguments. NoSQL injection, operator injection, and filter bypass all happen here.

Test 6: NoSQL Operator Injection in Variables

MongoDB-backed GraphQL APIs are particularly vulnerable. A where: { email: "user@example.com" } filter becomes where: { email: { $gt: "" } } — matching every document.

import pytest

NOSQL_INJECTION_PAYLOADS = [
    {"$gt": ""},
    {"$ne": None},
    {"$regex": ".*"},
    {"$where": "1==1"},
    {"$exists": True},
]

@pytest.mark.parametrize("payload", NOSQL_INJECTION_PAYLOADS)
def test_nosql_injection_via_variables_rejected(payload):
    """
    NoSQL operator objects in variables should be rejected or sanitized.
    They must never match unintended records.
    """
    query = """
    query FindUser($email: String!) {
      userByEmail(email: $email) {
        id
        email
      }
    }
    """
    response = requests.post(
        GRAPHQL_URL,
        json={"query": query, "variables": {"email": payload}},
        headers={"Content-Type": "application/json"}
    )

    # A properly typed GraphQL schema will reject non-String variables
    # at the schema validation layer before reaching resolvers
    data = response.json()

    assert "errors" in data, (
        f"NoSQL injection payload was accepted without error: {payload}. "
        f"Response: {data}"
    )

    # Extra check: no user data should be returned
    if "data" in data and data["data"]:
        assert data["data"].get("userByEmail") is None, \
            f"Query returned user data with injection payload: {payload}"

Test 7: Batch Query Injection (Query Aliasing Abuse)

GraphQL aliases let clients run the same query multiple times in one request. This can bypass rate limiting and brute-force login:

def test_aliased_mutation_rate_limit():
    """
    Batching the same mutation under multiple aliases should be rate-limited.
    This is commonly used to brute-force OTPs or passwords.
    """
    # Build a query with 50 aliased login attempts
    aliases = "\n".join([
        f"""
        attempt{i}: login(input: {{
            email: "victim@example.com",
            password: "guess{i:04d}"
        }}) {{
            token
            success
        }}
        """
        for i in range(50)
    ])

    batch_query = f"mutation {{ {aliases} }}"

    response = requests.post(
        GRAPHQL_URL,
        json={"query": batch_query},
        headers={"Content-Type": "application/json"}
    )

    # Should be rejected: either 429, or an error about batch size
    if response.status_code == 200:
        data = response.json()
        assert "errors" in data, (
            "50 aliased login mutations executed without error. "
            "Rate limiting or alias depth limiting is not in place."
        )
    else:
        assert response.status_code in [429, 400], \
            f"Expected 429 or 400 for batched brute-force, got {response.status_code}"

Query Depth and Complexity Limit Testing

No auth required for this attack. An anonymous user can crash your server with a deeply nested query.

Test 8: Recursive Query Depth Rejected

def build_nested_query(depth: int) -> str:
    """Build a query nested `depth` levels deep using a recursive relationship."""
    inner = "id name"
    for _ in range(depth):
        inner = f"id name friends {{ {inner} }}"
    return f"{{ me {{ {inner} }} }}"

@pytest.mark.parametrize("depth,should_pass", [
    (3, True),   # Normal usage
    (7, True),   # Reasonable max
    (15, False), # Should be blocked
    (50, False), # Should definitely be blocked
])
def test_query_depth_limits(depth, should_pass):
    """Server should enforce maximum query depth."""
    query = build_nested_query(depth)
    response = requests.post(
        GRAPHQL_URL,
        json={"query": query},
        headers={"Content-Type": "application/json"}
    )
    data = response.json()

    if should_pass:
        # Shallow queries should work
        assert "errors" not in data or not any(
            "depth" in e.get("message", "").lower()
            for e in data.get("errors", [])
        ), f"Query at depth {depth} was incorrectly rejected"
    else:
        # Deep queries must be rejected
        assert "errors" in data and any(
            "depth" in e.get("message", "").lower() or
            "complexity" in e.get("message", "").lower() or
            "limit" in e.get("message", "").lower()
            for e in data["errors"]
        ), (
            f"Query at depth {depth} was not rejected. "
            f"Response: {data}"
        )

Test 9: Query Complexity Limits

def build_high_complexity_query(field_count: int) -> str:
    """Request many fields in a single query to hit complexity limits."""
    fields = " ".join([f"field{i}: id" for i in range(field_count)])
    return f"{{ allUsers {{ {fields} }} }}"

def test_query_complexity_limit_enforced():
    """Overly complex queries should be rejected before execution."""
    # A query requesting 500 fields should hit complexity limits
    query = build_high_complexity_query(500)

    response = requests.post(
        GRAPHQL_URL,
        json={"query": query},
        headers={"Content-Type": "application/json"}
    )
    data = response.json()

    assert "errors" in data, \
        "High-complexity query (500 fields) executed without restriction"

    error_text = " ".join(e.get("message", "") for e in data["errors"]).lower()
    assert any(word in error_text for word in ["complexity", "limit", "too complex"]), \
        f"Expected complexity error, got: {data['errors']}"

Information Disclosure via Error Messages

GraphQL servers are verbose by default. Stack traces, database errors, and internal paths leak through errors[].extensions.exception.

Test 10: Stack Traces Not Exposed

def test_errors_do_not_expose_stack_traces():
    """
    Deliberate error-triggering requests should not return stack traces
    or internal implementation details.
    """
    # Send a malformed query that will trigger an internal error
    malformed_query = """
    {
      user(id: "' OR 1=1--") {
        id
        email
      }
    }
    """
    response = requests.post(
        GRAPHQL_URL,
        json={"query": malformed_query},
        headers={"Content-Type": "application/json"}
    )
    data = response.json()

    if "errors" in data:
        for error in data["errors"]:
            error_str = str(error).lower()

            # Stack trace indicators
            assert "at object." not in error_str, \
                "Node.js stack trace leaked in error response"
            assert "traceback" not in error_str, \
                "Python traceback leaked in error response"
            assert "stacktrace" not in error_str, \
                "Stack trace field present in error"

            # Database error leakage
            assert "mongoerror" not in error_str, \
                "MongoDB error message leaked"
            assert "sequelize" not in error_str, \
                "Sequelize ORM error leaked"
            assert "syntax error" not in error_str or \
                   "graphql" in error_str, \
                "SQL/DB syntax error might be leaking"

            # Internal path leakage
            extensions = error.get("extensions", {})
            assert "exception" not in extensions, (
                "Internal exception object in error extensions: "
                + str(extensions.get("exception"))
            )

Test 11: Field Suggestion Enumeration

GraphQL servers suggest similar field names on typos: "Did you mean adminPassword?" This leaks field names even with introspection disabled.

def test_field_suggestions_disabled():
    """
    Field name suggestions in error messages enumerate schema fields.
    This should be disabled in production.
    """
    query = """
    {
      user(id: "1") {
        adminPasswrd
      }
    }
    """
    response = requests.post(
        GRAPHQL_URL,
        json={"query": query},
        headers={"Content-Type": "application/json"}
    )
    data = response.json()

    if "errors" in data:
        for error in data["errors"]:
            message = error.get("message", "")
            assert "did you mean" not in message.lower(), (
                f"Field suggestion leaks schema info: '{message}'. "
                "Disable suggestions in production GraphQL config."
            )

Automated Security Scanning

Integrating with CI

Run security tests as a dedicated pipeline stage, not mixed with functional tests:

# .github/workflows/graphql-security.yml
name: GraphQL Security Tests

on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'  # nightly against production

jobs:
  graphql-security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install dependencies
        run: pip install pytest requests

      - name: Run introspection tests
        run: pytest tests/security/test_graphql_introspection.py -v

      - name: Run injection tests
        run: pytest tests/security/test_graphql_injection.py -v

      - name: Run authorization tests
        run: pytest tests/security/test_graphql_authorization.py -v

      - name: Run DoS protection tests
        run: pytest tests/security/test_graphql_dos.py -v

GraphQL-Specific Tooling

Beyond custom tests, run these against every environment:

  • graphql-cop — automated GraphQL security audit tool. Checks introspection, field suggestions, aliases, batch attacks, and more in one command.
  • InQL (Burp Suite extension) — generates all possible queries from schema for manual testing.
  • clairvoyance — reconstructs GraphQL schema from a server that has introspection disabled, by probing field name guesses.
# Install graphql-cop
pip install graphql-cop

# Run against your endpoint
graphql-cop -t https://api.yourapp.com/graphql -o json > graphql-security-report.json

Add graphql-cop to CI and fail the build if any HIGH severity issues are found:

graphql-cop -t $GRAPHQL_URL -o json | \
  python -c "
import json, sys
results = json.load(sys.stdin)
high_issues = [r for r in results if r.get('severity') == 'HIGH']
if high_issues:
    print('HIGH severity GraphQL security issues found:')
    for issue in high_issues:
        print(f'  - {issue[\"title\"]}: {issue[\"description\"]}')
    sys.exit(1)
"

Security Checklist

Introspection

  • Introspection disabled in production environments
  • __type queries return null or error in production
  • Field name suggestions disabled (prevents schema enumeration)
  • GraphiQL / Playground not accessible in production

Authorization

  • Every field resolver enforces authorization independently
  • Object-level authorization (IDOR) tested with cross-user requests
  • Admin-only fields return null for regular users, not an error that confirms existence
  • Mutations check authorization on each field, not just at entry

Injection

  • Variables with operator objects ($gt, $ne, $regex) rejected by schema typing
  • Scalar types are strict (String does not accept objects)
  • Aliased mutation batching is rate-limited or depth-limited
  • Query batching (array of operations) has a count limit

DoS Protection

  • Maximum query depth enforced (typically 7–10 levels)
  • Query complexity scoring and limits in place
  • Alias count limit enforced
  • Request timeout set (not just at HTTP layer — at resolver level)

Error Handling

  • Stack traces stripped from production error responses
  • Database error messages not surfaced to clients
  • Internal file paths not present in errors
  • extensions.exception removed from production responses

Scanning

  • graphql-cop or equivalent runs in CI on every PR
  • Nightly scan against production endpoint
  • Schema diff check — alert on new fields added without review

GraphQL security failures tend to be systematic, not one-off mistakes. An entire class of fields gets missed for authorization. Introspection stays on because someone forgot to add the production flag. The variables sanitization only covers the query string. Test these categories on every deployment, not just at launch — schema changes introduce new attack surface continuously.

Read more

Start now free