Detection Engineering

GraphQL Authorization Mistakes to Detect

Detect GraphQL authorization mistakes — BOLA argument manipulation, introspection exposure, and query-level-only checks, with Sigma rules and hardening.

A glowing cyan node graph over a dark surface with one node pulsing red for unauthorized access

GraphQL authorization fails in a specific, predictable way: the API checks “are you logged in?” at the query level but never “do you own this object?” at the field and object level. A client composes its own query, swaps in another user’s id, and the resolver hands back data it should have refused. This is Broken Object Level Authorization, and it is the GraphQL authorization mistake you most need to detect. This guide ships the detection logic and the resolver-level controls that close it.

GraphQL’s flexibility widens the attack surface — aliases, nested selections, batched mutations — so REST-era, route-based authorization stops applying. BOLA maps to OWASP API1:2023 and MITRE ATT&CK T1190 / T1078. The 2025 Parse Server flaw CVE-2025-53364 — public, unauthenticated access to the GraphQL schema — is the worked example.

What is a GraphQL authorization mistake?

A GraphQL authorization mistake is any gap that lets a client reach data or operations it should not. Because the client composes the query, a single endpoint exposes the whole graph — so authorization has to be enforced per object and per field in the resolvers, not at a route. The most common and damaging gap is BOLA: the resolver trusts a client-supplied id argument without checking ownership.

This is the same class as broken access control in REST, expressed through GraphQL arguments. The detection philosophy carries over too: authorization decisions are events, and abuse has a shape.

What are the most common GraphQL authorization mistakes?

MistakeWhat the attacker doesTelemetry fingerprintDefense
Query-level-only authz (BOLA)Swaps id arguments to read others’ dataOne actor, many distinct object IDsObject/field-level authz
Broken function-level authz (BFLA)Calls admin queries/mutations as a low roleLow-role actor hitting privileged operationsRole checks per operation
Introspection exposureMaps the full schema, finds hidden fieldsIntrospection query, esp. unauthenticatedControl/auth introspection
Alias / batch abuseAliases to brute force or batch many objectsMany aliased fields in one operationDepth/cost limits, rate limiting

The unifying lesson: GraphQL authorization is per-object and per-field, and the detection signal is one actor pulling far more of the graph than their role should — through arguments, aliases, or batches.

How to detect GraphQL authorization abuse

The prerequisite is logging GraphQL operations with the actor, operation name, and arguments — without that, the API is a black box. With it, two patterns surface.

Unauthenticated introspection queries

An introspection query (__schema, __type) from an unauthenticated or low-trust client is schema reconnaissance.

Sigma GraphQL Introspection Query From an Unauthenticated Client
title: GraphQL Introspection Query From an Unauthenticated Client
id: 4c9e2b71-darkpwn-illustrative
status: experimental
logsource:
  product: application
  service: graphql
detection:
  selection:
    query|contains: ['__schema', '__type', 'IntrospectionQuery']
  filter_authed:
    auth_state: 'authenticated'
  condition: selection and not filter_authed
falsepositives:
  - Developer tooling/playground in non-prod (disable introspection in prod)
level: medium

BOLA enumeration via argument manipulation

A single actor pulling many distinct object IDs through query arguments is the BOLA signature — the GraphQL form of the object-ID enumeration seen in REST.

SPL Single Actor Enumerating Many Object IDs via GraphQL Arguments
index=app sourcetype=graphql:operation
| rex field=arguments "id\"\s*:\s*\"(?<obj_id>[^\"]+)\""
| bin _time span=5m
| stats dc(obj_id) AS distinct_ids by _time, actor_id, operation_name
| where distinct_ids >= 50

Where should GraphQL authorization actually live?

“Enforce it in the resolvers” is the standard advice and it is only half an answer, because it describes a location without describing a guarantee. Four architectures are in common use and they fail differently.

PatternHow it worksFailure mode
Schema directives (@auth, @hasRole)Declarative annotations on types and fieldsCoarse — expresses roles well, ownership badly
Per-resolver checksEach resolver validates the principalOpt-in, so a new resolver ships unprotected
Data-layer enforcementEvery query is scoped to the principal at the repositoryHardest to retrofit; the safest afterwards
External authorization serviceA dedicated relationship-based service answers each decisionLatency and operational weight

The distinction that matters is opt-in versus enforced by construction. Per-resolver checks are opt-in: the code is correct only if every author remembers, on every resolver, forever. A single new field added during a busy sprint is a BOLA vulnerability, and nothing about the codebase signals its absence. Reviews catch some of these and reviewers are the same people under the same deadline.

Data-layer enforcement inverts that. If your repository layer cannot construct a query without a principal scope — because the function signature requires it — then a resolver that forgot to check authorization still cannot return another user’s data. The check is not a step someone performs; it is a property of the only available path to the data.

That is a real refactor and it is the difference between an application that is currently secure and one that stays secure as it grows. If you do only one thing after reading this, make the absence of an authorization check a compile-time or construction-time impossibility rather than a review finding.

Disabling introspection does not hide your schema

This is the most consequential misconception in GraphQL security, and it causes teams to consider a problem closed when it is not.

Turning off introspection removes the convenient path to the schema. It does not remove the schema from an attacker’s reach, because most GraphQL servers are helpful in a way that leaks it: field suggestions. Request a field that does not exist and the server frequently replies with “Did you mean emailAddress?” That single behaviour lets an attacker reconstruct type and field names by brute-forcing candidate strings and reading the corrections — and tooling to automate exactly this has existed for years.

The practical consequence:

  • Disable field suggestions in production, not just introspection. In graphql-js-based servers this is separate configuration, and it is the step almost everyone misses.
  • Return generic errors. Verbose validation errors, stack traces, and raw database messages in the errors array disclose schema, ORM structure, and sometimes data. Log the detail server-side; return a reference ID to the client.
  • Treat the schema as public regardless. Even with both controls in place, a determined attacker with access to your client application can read the queries it sends. Schema secrecy is a speed bump; authorization is the control. This is the actual lesson of CVE-2025-53364, and it is worth stating in stronger terms than “also turn introspection off.”

Why depth limits are not cost limits

Depth limiting is the first control most teams add and the one that provides the least protection, because expense in GraphQL is a product of depth and breadth, and depth limits only bound one of them.

A query five levels deep is bounded by a depth limit. A query two levels deep that requests the same expensive field two hundred times under two hundred different aliases is not — it is shallow, it passes, and it costs two hundred resolver executions. Batching compounds this: many servers accept an array of operations in a single HTTP request, so one request can carry many queries, and every rate limit counting requests sees exactly one.

The controls, in ascending order of effectiveness:

ControlWhat it boundsWeakness
Depth limitNesting onlyBlind to aliasing and breadth
Field/node count limitTotal selectionsCrude; weights all fields equally
Complexity or cost analysisWeighted cost per field, budget per queryRequires assigning weights and maintaining them
Persisted queries / trusted documentsOnly pre-registered operations runNeeds build-time integration; blocks ad-hoc clients

Persisted queries are the strongest control by a wide margin. The client sends a hash of a query registered at build time rather than the query text, so an attacker cannot compose a novel query at all — the entire category of alias abuse, introspection, and unexpected traversal disappears, because arbitrary queries are simply not executable. If your GraphQL API serves only your own first-party clients, this is achievable and it converts most of this post’s attack surface into a non-issue.

Where you cannot use persisted queries, cost analysis is the meaningful control. Depth limiting on its own creates a comfortable feeling and stops very little.

Rate limit on cost, not on requests. A GraphQL request is an arbitrary amount of work, so requests-per-minute is close to meaningless as a budget. Charge each principal a computed cost per operation and limit the total, which is the only formulation that reflects what the server actually spends.

What should you log without creating a privacy problem?

“Log the operation and arguments” is necessary for detection and, taken literally, it puts customer data into your SIEM. Arguments contain email addresses, names, search terms, and whatever else the application accepts. Query responses are worse. This matters both for privacy law and because a SIEM tends to have broader access and longer retention than the database it is describing.

A workable middle:

  • Log the operation name and a hash of the query document. The hash identifies the shape of the operation and correlates repeats without storing the text.
  • Log argument keys, and argument values only for identifiers. Object IDs are what the enumeration detection needs. Free-text arguments are what the privacy problem is made of.
  • Log a count of returned objects, never the objects. Volume is the anomaly signal, and it is available without retaining any of the data.
  • Log the principal, the role, the source IP, and the computed cost. All four are needed for triage, and none of them are user content.

That set supports every detection in this guide — enumeration, alias fan-out, unauthenticated introspection, and privilege anomalies — while keeping user content out of a system that was not designed to hold it.

How do you triage a GraphQL enumeration alert?

  1. Compare against the principal’s own baseline, not a global threshold. A support role legitimately reads many customer records; an end-user account does not. Thresholds without per-role baselines produce alerts that are either useless or ignored.
  2. Check whether the IDs are sequential or scattered. Sequential enumeration is automated and unambiguous. Scattered IDs suggest the attacker already has a list, which is a different and usually worse finding — it means an earlier disclosure you have not detected yet.
  3. Determine whether the requests succeeded. A run of authorization denials is your controls working and still worth investigating. A run of successes is a live data breach with a quantifiable record count.
  4. Count what was actually returned. The disclosure scope is objects returned, not requests sent, and these differ by orders of magnitude when aliases are involved.
  5. Look for the mutations. Read enumeration is frequently reconnaissance for a write. Check whether the same principal called any mutation in the window, since mutation authorization is reviewed less often than query authorization on almost every codebase.
  6. Identify the vulnerable resolver, not just the operation. The fix is a scope on one data path, and finding it is what makes the response durable.
  7. Assume the same gap exists elsewhere. If one resolver was missing an ownership check, others written by the same team in the same period likely are too. Audit by pattern rather than fixing the single reported instance.

Step 3 is the one that determines what kind of incident this is, and it is remarkably often skipped in favour of blocking the source IP — which stops the current session and leaves both the vulnerability and the question of what was already taken entirely unaddressed.

How to test your GraphQL authorization

In a staging API you own:

  1. As user A, request user B’s object IDs through query arguments; every one should be denied, not returned.
  2. As a low-privilege role, call an admin query/mutation; it should be refused.
  3. Send an introspection query unauthenticated; confirm it is blocked and alerts.
  4. Send an alias/batch operation pulling many objects; confirm the cost/depth limit and the enumeration alert both trip.

How to defend against GraphQL authorization mistakes

  • Layer the other controls (per Escape/StackHawk guidance): query allowlisting, depth and cost limits, GraphQL-aware rate limiting, and CSRF protection. No single control is enough.
  • Control introspection — disable it in production unless you truly need it, and never expose it unauthenticated.
  • Validate and bound inputs — reject overly nested or expensive operations before they execute, and reject them during validation rather than partway through execution, so a rejected query costs nothing.

Common GraphQL detection mistakes

  • No operation/argument logging. The single endpoint hides everything; you must log the operation and arguments.
  • Request-count thresholds only. Aliases and batching defeat them — count objects per request.
  • Treating introspection-off as the fix. The gap is missing authz, not the feature.
  • Reusing REST route rules. GraphQL needs object/field-level checks.
  • Disabling introspection but leaving field suggestions on. “Did you mean…” reconstructs the schema for anyone patient enough to brute-force field names.
  • Depth limits treated as cost limits. A shallow, wide, alias-heavy query passes every depth check and costs hundreds of resolver executions.
  • Rate limiting by request count. One GraphQL request is an arbitrary amount of work, and batching puts many operations inside one request.
  • Authorization in resolvers but batching underneath. A DataLoader that fetches in bulk without the principal scope retrieves objects the caller cannot see.
  • Reviewing query authorization and forgetting mutations. Writes are consistently the less audited half of the schema.
  • Logging raw queries and arguments. It works for detection and moves customer data into a system with broader access and longer retention than the database.
  • Blocking the source IP and closing the alert. That ends the session and leaves both the vulnerable resolver and the question of what was already returned untouched.
  • Verbose errors in production. Stack traces and ORM messages disclose schema and sometimes data; return a reference ID and keep the detail server-side.

GraphQL authorization checklist

  1. Enforce object- and field-level authorization in resolvers, scoped to the principal.
  2. Add RBAC/role checks on privileged queries and mutations (BFLA).
  3. Disable introspection in production; never expose the schema unauthenticated.
  4. Apply query depth limits, cost analysis, and GraphQL-aware rate limiting.
  5. Allowlist known operations (persisted queries) where feasible.
  6. Log every operation with actor, operation name, and arguments.
  7. Alert on object-ID enumeration, alias/batch fan-out, and unauthenticated introspection.
  8. Test cross-user argument access and admin-operation access per role in CI.
  9. Disable field suggestions and verbose errors in production, not just introspection.
  10. Move the principal scope into the data-access layer so an unprotected resolver cannot return another user’s data, and put it inside batch loaders rather than around them.
  11. Adopt persisted queries where clients are first-party — it removes arbitrary query composition entirely.
  12. Rate limit on computed query cost rather than request count.
  13. Log operation names, query hashes, argument keys, identifier values, and result counts — never free-text arguments or response bodies.

Items 10 and 11 are the two that change the shape of the problem rather than raising its cost. Everything else on this list is a control someone has to remember to apply to each new field; those two make the insecure version unbuildable. Given a limited amount of engineering time, spend it there — a per-resolver check protects one resolver, and a data layer that cannot construct an unscoped query protects every resolver anyone writes afterwards, including the ones added long after this decision is forgotten.

The takeaway

GraphQL authorization mistakes are REST’s broken access control wearing a new syntax: authorize per object and per field in resolvers, control introspection, and bound query cost. Detect by logging operations and arguments and alerting on the actor who pulls more of the graph than their role should. Pair this with broken access control testing and JWT misconfiguration detection for full API-security coverage, or browse the Detection Engineering pillar.

Training & tools referenced

Disclosure: Some links below are affiliate links. If you buy through them, darkpwn may earn a commission at no extra cost to you. We only recommend training and tools we actually use in our own lab, and affiliate links never influence editorial coverage.

  • TryHackMeAuthorized labs to practice API and GraphQL authorization testingSecurity Training
    Start training

Frequently asked questions

What is the most common GraphQL authorization mistake?

Checking authorization only at the query level instead of the object and field level. A user passes another user's id as an argument and the resolver returns the object without verifying ownership — Broken Object Level Authorization (BOLA). The fix is field- and object-level authorization enforced in resolvers.

Should you disable GraphQL introspection?

For private APIs you do not need introspection, so turning it off reduces schema disclosure. But introspection is not the real problem — missing authentication and authorization around it is, as CVE-2025-53364 showed. Control access to introspection rather than relying on disabling it alone.

How do you detect GraphQL authorization abuse?

Log GraphQL operations with the actor, operation name, and arguments, then alert on a single actor requesting many distinct object IDs via argument manipulation (BOLA enumeration), alias-based brute force, and introspection queries from unauthenticated clients.

What is BOLA in GraphQL?

BOLA (Broken Object Level Authorization) is when the API trusts a client-supplied object id without checking the requester owns it — the same flaw as IDOR in REST. In GraphQL it appears as argument manipulation on queries and mutations and maps to OWASP API1.