How to Rate-Limit GraphQL Operations Through WAF

December 13, 2028 · 13 min read

Security · SCS-C03 · part of The Exam Room

The situation

An API migration moved a REST backend to GraphQL on AppSync. A single endpoint, /graphql, accepts POSTs with a JSON body containing query, variables, and optionally operationName. The front-end clients call ~50 distinct operations ranging from GetViewerProfile (cheap, 1 DynamoDB read) to SearchProducts (moderate, 2-3 requests to Elasticache + DDB) to GenerateMonthlyReport (expensive, 30+ seconds, touches a spreadsheet-sized dataset).

Existing WAF ACL on the AppSync API:

  • Generic rate-based rule: 2,000 requests per 5 minutes per IP.
  • AWS managed rule groups: CommonRuleSet, KnownBadInputsRuleSet, SQLiRuleSet.

Three concrete problems:

  • Expensive-query abuse. A single IP sends 500 GenerateMonthlyReport calls in an hour; each takes 30 seconds and costs real compute. The generic rate limit hasn’t fired because 500 in 60 minutes is well under 2,000 in 5 minutes.
  • Introspection queries from unauthenticated scanners. Attackers probing the schema by sending { __schema { types { name } } }. Not blocked by any managed rule.
  • Mutation flood against onboarding. SignUp mutation being hit 100 times per minute per IP by a bot network creating fake accounts. The per-IP rate is too low to trigger the generic rule but high enough to stress downstream.

Generic WAF rules don’t solve these because every request is the same URI and HTTP method. The discrimination has to happen on the body.

What actually matters

GraphQL breaks the assumption most HTTP-level firewalls are built on: that the URL is a reasonable approximation of intent. A REST API has /users, /orders, /reports; rate-limit the report endpoint separately from the users endpoint. A GraphQL API has /graphql for all of it. The intent is inside the POST body, in a nested JSON field, expressed as a string of GraphQL query syntax.

JSON body inspection is the lever. If the firewall can parse the POST body and navigate into the JSON tree, the rule can match on a specific field, apply text transformations, and use the usual predicates (string contains, regex, size, etc.).

That unlocks three new tools.

Operation-name rate limiting. A rate-based rule that aggregates by a composite of source IP and a body field can express “limit each IP to N calls per window of a specific operation,” scoped down to requests where the body field equals that operation name.

Query-content pattern matching. Custom rules that inspect the query string inside the body and match on regex, providing a way to block introspection probes or flag query shapes that proxy for expense.

Variables pattern matching. Match on a nested input path. Useful when a specific input field is being abused (e.g. an email field with test-domain patterns indicating fake signups).

What we’ll filter on

  1. Body size coverage. Does the rule see the whole body?
  2. JSON path precision. Can we navigate to specific fields?
  3. Oversize handling. What if the body exceeds the inspectable limit?
  4. Aggregation. Can we rate-limit by a body field, not just IP?
  5. Deep-parse behaviour. Does WAF handle nested objects, arrays, escaped strings?

The WAF body-inspection landscape

1. Body field match (raw bytes). The classic approach. Inspect the raw POST body as a single byte buffer, run regex or string match. Works, but doesn’t understand JSON structure: {"query":"{ __schema }"} and {"other":"{ __schema }"} both match a regex for __schema, even though only the first is actually an introspection query.

2. JsonBody field match with JSON pointer. The modern approach. WAF parses the body as JSON; the rule specifies which JSON path to inspect, which text transformation to apply, and which predicate to match. Statements in the AWS WAF JSON rule schema under FieldToMatch.JsonBody with a MatchPattern specifying either All or IncludedPaths.

3. Body-size inspection. Rules can match on body size (e.g. BLOCK if body > 64KB on this endpoint). Useful guard rail against oversized query bombs.

4. AWSManagedRulesATPRuleSet (Account Takeover Prevention). Paid managed rule group that inspects login POST bodies for credential-compromise signals. Works on JSON or form bodies; configured with the JSON paths to the username and password fields. Overlaps conceptually with the GraphQL case: “inspect a specific JSON path during POST.”

5. Rate-based rules with custom aggregation keys. The rule statement uses CustomKeys: a list of keys (IP, header, cookie, query arg, forwarded IP, label, or body field). Aggregation counts requests per unique combination of keys. Composite-key rate limiting is the primary tool for per-operation limits.

6. AppSync-native controls. AWS AppSync has its own features: query depth limits, rate limiting per API key, query complexity analysis. Complementary to WAF: AppSync stops expensive queries server-side; WAF stops abuse before it reaches AppSync.

Side by side

Option Body coverage Path precision Oversize Rate agg key Deep parse
Body raw match 8-64 KB None MATCH / NO_MATCH / CONTINUE IP only No
JsonBody match 8-64 KB Full JSON pointer MATCH / NO_MATCH / CONTINUE / EVALUATE_AS_STRING Any body field Yes
Body size match N/A (size only) N/A N/A N/A No
ATP managed Configured paths Login-specific Managed Credential signal Yes
Rate-based + JsonBody key 8-64 KB Via aggregation Same as JsonBody Composite Yes
AppSync depth/complexity Full body (server-side) Full AST N/A Per-API/per-key Yes (AST)

Reading the table: JsonBody + rate-based + composite aggregation keys is the heart of the GraphQL defence; AppSync’s own depth/complexity limits are the server-side complement.

The inspection path, end to end

Client request POST /graphql Content-Type: application/json { operationName, query, vars } AWS WAF Web ACL (evaluation order) Priority 0 — Body size if size > 64 KB → BLOCK Priority 10 — JsonBody introspection pattern $.query matches /\b__schema\b|\b__type\b/ → BLOCK Priority 20 — Rate-based, composite key agg: (IP, $.operationName) limits table per-operation Priority 30 — Managed rule groups CommonRuleSet + KnownBadInputs + SQLi Default action: ALLOW (with logging) AppSync API query depth <= 8 query complexity <= 200 per-API rate limit resolvers → data sources Data sources DynamoDB, Lambda, Elasticache, HTTP Rate-based per-operation limits (priority 20) GenerateMonthlyReport: 5/5min per IP SignUp: 10/5min per IP default: 200/5min per IP One rate-based rule per sensitive operation; aggregation key is (IP, operationName)
Requests flow through body-size, JSON-body pattern, composite-rate, and managed rule groups before reaching AppSync's own complexity limits.

The picks in depth

Body-size rule as the cheap first filter. WAF inspects up to 8 KB of body by default on regional endpoints (ALB, AppSync, API Gateway) and 64 KB on CloudFront. AssociationConfig.RequestBody can raise the AppSync limit to 64 KB too; higher than that requires accepting that the rest is uninspected. For GraphQL, oversized queries are usually not legitimate; a 200 KB query is somebody trying something. First rule: BLOCK if body > 64 KB. Cheap (2 WCU), catches the obvious.

JsonBody introspection block. Rule statement:

{
  "Statement": {
    "RegexMatchStatement": {
      "RegexString": "\\b__schema\\b|\\b__type\\b",
      "FieldToMatch": {
        "JsonBody": {
          "MatchPattern": {
            "IncludedPaths": ["/query"]
          },
          "MatchScope": "VALUE",
          "InvalidFallbackBehavior": "EVALUATE_AS_STRING",
          "OversizeHandling": "MATCH"
        }
      },
      "TextTransformations": [
        { "Priority": 0, "Type": "NONE" }
      ]
    }
  },
  "Action": { "Block": {} }
}

IncludedPaths: ["/query"] tells WAF to inspect only the JSON value at $.query. MatchScope: VALUE inspects the value, not keys. InvalidFallbackBehavior: EVALUATE_AS_STRING says “if this isn’t valid JSON, treat the whole body as a string and still check the pattern”, a defence against attackers sending malformed JSON to bypass parsing. OversizeHandling: MATCH blocks oversized bodies that couldn’t be fully parsed. Whitelist legitimate introspection from your CI/test account by putting an allow rule with the CI account’s IP range above this rule in Web ACL priority.

Composite-key rate-based rule for expensive operations. One rate-based rule per sensitive operation. Example for GenerateMonthlyReport:

{
  "Statement": {
    "RateBasedStatement": {
      "Limit": 5,
      "EvaluationWindowSec": 300,
      "AggregateKeyType": "CUSTOM_KEYS",
      "CustomKeys": [
        { "IP": {} },
        { "JsonBody": {
            "MatchPattern": { "IncludedPaths": ["/operationName"] },
            "MatchScope": "VALUE",
            "InvalidFallbackBehavior": "NO_MATCH",
            "OversizeHandling": "NO_MATCH"
          }
        }
      ],
      "ScopeDownStatement": {
        "ByteMatchStatement": {
          "SearchString": "GenerateMonthlyReport",
          "FieldToMatch": {
            "JsonBody": {
              "MatchPattern": { "IncludedPaths": ["/operationName"] },
              "MatchScope": "VALUE"
            }
          },
          "PositionalConstraint": "EXACTLY",
          "TextTransformations": [{ "Priority": 0, "Type": "NONE" }]
        }
      }
    }
  },
  "Action": { "Block": {} }
}

Aggregation key: source IP plus the value of $.operationName. Scope-down: only count requests where operationName is exactly GenerateMonthlyReport. Limit: 5 per 5-minute window. A legitimate user or admin running the report occasionally is under the limit; a bot hammering it is blocked.

Repeat for each sensitive operation: SignUp at 10/5min, SendVerificationEmail at 3/5min, ImportBulkData at 1/5min. Default rate-based rule (aggregating on IP only, no scope-down) underneath catches operations not specifically listed.

AppSync server-side complements. Query depth and complexity analysis at the AppSync level stops queries WAF can’t precisely evaluate. AppSync’s additionalAuthenticationProviders and defaultAuthentication with API keys means you can apply different quotas per client tier.

A worked request

An attacker POSTs:

{
  "query": "query M { generateMonthlyReport { rows { date amount } } }",
  "operationName": "GenerateMonthlyReport"
}

from IP 203.0.113.5, 30 times in 3 minutes. Request #1-#5 pass WAF (under limit). Request #6: rate-based rule evaluates, composite key (203.0.113.5, "GenerateMonthlyReport") has 5 hits in the 5-minute window, threshold exceeded, action = BLOCK. The WAF logs show the blocked requests with the operation name captured, aggregation key, and rule ID. Attacker switches IP; count restarts (because aggregation is per-IP composite), attacker hits 5 more, blocked. The cost to the attacker per blocked batch is whatever a new IP costs to rotate to; the cost to AppSync per blocked batch is near zero (WAF short-circuits before AppSync).

A legitimate admin user runs the same operation once at 09:00 from their laptop IP, once more at 13:00. Neither request is close to the limit. The admin’s experience is unchanged.

What’s worth remembering

  1. GraphQL breaks URL-based firewall rules. Every request shares a URL; the intent is in the body.
  2. JsonBody field match is the core primitive. Inspect specific JSON paths; evaluate regex, byte-match, size predicates on the values.
  3. Rate-based rules accept composite keys. Source IP plus a body field (operation name) lets you rate-limit per-operation.
  4. Body size limits are real. 8 KB default on regional, 64 KB on CloudFront; AssociationConfig.RequestBody raises regional to 64 KB.
  5. OversizeHandling and InvalidFallbackBehavior matter. Decide what to do with bodies that can’t be fully parsed; err toward MATCH/BLOCK for stricter posture.
  6. Block GraphQL introspection in production. Pattern on __schema / __type in $.query; allow-list your CI.
  7. AppSync’s own limits complement WAF. Query depth, complexity, and per-API-key rate limits run server-side and catch what WAF can’t.
  8. Log everything. Firehose WAF logs to S3 with Athena on top; the false-positive review and the rule-tuning feedback loop both live there.

A GraphQL endpoint looks like one URL; behind it, fifty operations with very different costs. Body-aware WAF rules are how the firewall stops treating them all the same.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.