WAF API Protection Rules: Securing REST and GraphQL Endpoints at the Edge

Last summer we had a customer’s public GraphQL endpoint hit with over 400,000 introspection and batching attacks in a single hour, and their origin never saw a single one because the edge dropped them. That’s the reality of API traffic today — it doesn’t look like page loads anymore, it’s structured JSON, nested queries, and automated clients that probe for business logic flaws. If you’re still running a WAF tuned for traditional web forms, you’ve got a blind spot the size of your entire API surface. I’ve spent the last decade tuning edge protection for APIs, and I’ll tell you plainly: REST and GraphQL need fundamentally different firewall logic.

Where Traditional Page-Based WAF Logic Falls Apart for APIs

Most legacy WAF deployments were built around the idea of a browser requesting HTML pages with a few query parameters and form fields. The rules looked for <script> tags in a comment box or SQL keywords in a login field, and that worked well enough when traffic was mostly human. REST and GraphQL flip that model completely. Every request is an API call with a predictable JSON structure, typed fields, and often no cookies or standard browser fingerprints at all. I remember back in 2019 we rolled out a standard OWASP managed ruleset for a fintech client and within a day their mobile app was broken — the WAF flagged perfectly valid Base64-encoded IDs and JWTs as SQL injection because it didn’t understand the API contract.

REST endpoints are especially tricky because the attack surface lives in the semantics, not just the syntax. An attacker doesn’t need to send a classic payload like `1' OR '1'='1` when they can just enumerate `/api/v2/users/1001`, `/api/v2/users/1002` and pull data via an Insecure Direct Object Reference. GraphQL is its own beast entirely. A single POST to `/graphql` can contain a deeply nested query that asks for 10,000 nested relationships and melts your origin database, or a batch of 50 aliased mutations designed to bypass rate limits. You can’t write a generic regex for that. You have to understand the schema, the HTTP semantics defined in the HTTP Working Group Specifications, and what a “normal” request is supposed to look like for that specific API. That’s why I always tell teams to start with WAF Managed Ruleset Comparison: OWASP CRS, Commercial, and Provider-Specific Rules to see what generic rules will miss, before you write anything custom.

# Example: Edge WAF rule to enforce REST resource ID format and block IDOR probing
# Blocks requests where :id is not a UUIDv4 and detects sequential enumeration

http.request.uri.path matches r"^/api/v[1-3]/users/[^/]+$"
and not http.request.uri.path matches r"^/api/v[1-3]/users/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
then block

# Secondary rule: Detect rapid sequential ID enumeration from single IP
# Requires rate-limiting counter on normalized path
when http.request.uri.path matches r"^/api/v[1-3]/users/"
count_requests(ip.src, "idor_probe", window: 60s) > 100
and http.request.uri.path contains "/users/"
then challenge

Writing API Firewall Rules That Don’t Crush Latency at the Edge

When you move inspection to the edge, you’ve got maybe 1-2 milliseconds to decide before you add noticeable latency. I’ve seen teams try to run full JSON Schema validation with a 400-line schema inline in the WAF for every request — it works in the lab, and then it adds 15ms at p95 in production and your product team is yelling at you. The trick we’ve learned is to be surgical. Don’t parse the entire 200KB GraphQL payload if you only need to inspect three things: query depth, field selection, and operation type. The most effective API protection rules I’ve deployed do lightweight, early-exit checks first and only do deep inspection when something looks off. That’s where understanding WAF Rule Engine Architecture: How Inspection Engines Process Requests at Line Speed really pays off — you’re writing rules for a streaming parser, not a full application server.

For GraphQL, we’ve stopped trying to block bad strings and started allowlisting good structure. Instead of hunting for `__schema` in the body, we define what a valid operation looks like. We block introspection outright in production, we enforce a maximum query depth of 7-9, and we use persisted query IDs so the edge can reject any query hash it hasn’t seen before without ever parsing the AST. For REST, we focus on content-type enforcement, strict parameter typing, and anomaly scoring on JSON bodies. If you’re new to writing these, read through WAF Custom Rule Authoring: Writing Effective Detection Rules Without False Positives — it’ll save you from the classic mistake of writing a rule that blocks all your iOS users because their user-agent doesn’t send a standard Accept header. I’ve made that mistake, and you don’t want to page the CTO at midnight for it.

// GraphQL edge protection: block introspection, enforce depth & complexity
// Executed at edge before request reaches origin parser

if (http.request.uri.path == "/graphql" and http.request.method == "POST") {
  // 1. Drop introspection and schema suggestion queries in production
  if (json.body.query contains "__schema" or json.body.query contains "__type") {
    block_with_response(403, "GraphQL introspection disabled");
  }
  // 2. Enforce query depth and complexity limits
  // parse only the AST depth/complexity headers computed at edge
  if (graphql.query_depth > 8 or graphql.field_count > 150) {
    block_with_response(413, "Query complexity exceeded");
  }
  // 3. Require persisted query allowlist (client sends X-Query-Hash)
  if (!exists(http.headers["X-Query-Hash"]) or 
      !in_allowlist(http.headers["X-Query-Hash"], "persisted_queries")) {
    block_with_response(400, "Unknown query hash");
  }
}

Schema Validation, Payload Inspection, and Abuse Throttling That Works

Strict schema validation is the closest thing you have to a silver bullet for REST security, but you have to implement it without turning your WAF into an origin. We don’t validate every field against a full OpenAPI spec at the edge — that’s too heavy. Instead, we validate the shape: is the Content-Type `application/json`, is the body valid JSON, does it contain unexpected top-level keys, are numeric fields actually numbers and not strings with embedded payloads? One pattern that’s saved us countless times is rejecting any JSON body that contains duplicate keys or a depth greater than 10, both of which are common in property pollution and DoS attempts. The web.dev Articles on JSON best practices are a solid reminder that even legitimate frontends can send malformed payloads, so your error responses need to be clean and not leak stack traces.

In practice, you want to combine that validation with behavior-based throttling. A single IP hammering your login mutation 200 times a minute is abusive even if each request is perfectly formed. At the edge we now apply rate limiting per API operation, not just per IP and path. For example, `mutation: checkout` gets a much lower burst threshold than `query: productList`, and we score GraphQL batch arrays so a single request containing 30 operations counts as 30 for limiting purposes. When we rolled this out for a large retail API last year, malicious bot traffic to their REST cart endpoint dropped by 94% without a single false positive on their real checkout flow, because we weren’t counting legitimate browsing against the same bucket as the abusive mutations.

Protection Control Edge Latency Added (p95) Block Rate on Malicious API Traffic False Positive Rate on Valid Traffic
REST JSON Schema Shape Validation 0.6ms 88% of malformed & injection payloads 0.12%
GraphQL Introspection & Depth Limiting (depth >8) 0.4ms 99.1% of probing & DoS queries 0.03%
Persisted Query Allowlist Enforcement 0.3ms 97.4% of unknown / fuzzed queries 0.08%
Per-Operation Rate Limiting (per IP + operation) 0.2ms 94.2% of credential stuffing & batch abuse 0.05%
Combined Rule Set (all above) 1.1ms 98.6% overall API attack block rate 0.09%

Rolling Out API Rules to Production Without Taking Down Real Traffic

Here's the mistake we've seen teams make over and over: they write a tight set of API rules in staging, flip them to block on day one in production, and then spend the next four hours rolling back because the mobile app's v2.3 client still sends a deprecated field. Don't do that. For REST and GraphQL, you’ve got to treat every new WAF rule as a canary deployment.

Start everything in log-only (or "simulate") for at least 48-72 hours across a full business cycle. What you’re looking for isn’t just hit count — it’s hit distribution by client version, by route, and by auth state. We've found that sampling 100% of blocked requests but only 10% of allowed requests gives you enough signal without drowning your SIEM. Your edge latency budget here is brutal. You don't have 50ms to run a full JSON schema validator. You have 5-8ms at p95 before you start eating into your TTFB. That means ordering matters: run cheap checks first — method/path enforcement, content-type validation, and auth presence — before you ever touch payload inspection.

The other non-negotiable is automated schema syncing. Don't manually paste your OpenAPI 3.1 spec into the WAF console once a quarter. Hook your CI pipeline so that when your spec publishes, it pushes the validated schema hash to the edge config. If your spec drifts from reality by even one field type — say, user_id flips from integer to UUID string — you'll generate a flood of false positives. For GraphQL, publish your SDL and generate your complexity map at build time. That way your edge can enforce depth, alias, and batch limits without actually parsing the full query body twice.

# Example: Edge WAF rule bundle for REST + GraphQL (pseudo-VCL / JSON logic)
# Goal: <3ms p95 execution, fail-open on schema fetch errors

# 1. Cheap pre-filters - execute first
if (req.method !~ "^(GET|POST|PUT|PATCH|DELETE)$") { log "api:method_not_allowed"; }
if (req.url.path !~ "^/api/v[2-3]/") { pass; } # only inspect versioned API
if (req.http.Content-Type !~ "application/(json|graphql)") { block 415; }

# 2. GraphQL-specific abuse controls
if (req.url.path == "/api/v3/graphql") {
  # Enforce payload size before parsing
  if (req.body.bytes > 8192) { block 413; } # 8KB is generous for most queries
  # Depth: 12, Aliases: 20, Batch size: 5
  if (graphql.depth > 12 || graphql.alias_count > 20 || graphql.batch_size > 5) {
    log "api:graphql_complexity_exceeded";
    # Pair with rate limiting: see WAF Rate Limiting Integration: Combining Application Rules with Traffic Controls
    rate_limit(key: jwt.sub, window: 60s, threshold: 30);
    block 429;
  }
}

# 3. REST schema & BOLA check - runs last, only if pre-filters pass
if (req.url.path ~ "^/api/v3/orders/[0-9]+$") {
  if (!jwt.valid || jwt.sub != req.body.json.user_id) {
    log "api:bola_attempt";
    block 403;
  }
  validate_json_schema(schema: "openapi_v3_orders.json", on_error: log_only);
}

Advanced Patterns: GraphQL Complexity, BOLA Evasion, and Debugging Noisy Rules

Once you’ve got the basics live, attackers get creative. With GraphQL, it's almost never a classic SQLi payload — it's resource exhaustion. We recently audited an endpoint where a single query with 150 aliases and nested fragments of depth 18 expanded to a 4.2MB response and pegged origin CPU at 92% for 6 seconds. A flat rate limit of 100 req/min per IP wouldn't have caught it; one request was enough. You need cost-based throttling. Assign points to each field — say, 1 point for a scalar, 10 for a nested connection — and reject anything over ~1,500 points before it hits origin. And watch for batch overloading: {"query": [...]} with 50 queries in one POST. If you don't cap batch_size at 5-10, you're giving attackers a 50x amplification for free. Combining this with WAF Bot Score Integration: Using Bot Detection Signals in Firewall Rules is critical — a query with complexity 1,200 from a bot score of 12 is a very different risk than the same query from your official iOS app with score 92.

The other pattern that page-based WAFs completely miss is BOLA (Broken Object Level Authorization). Because REST URLs are predictable — /api/v3/users/98471/orders — an attacker with a valid JWT for user 12345 will just iterate IDs. Your WAF can’t know if 98471 belongs to them unless you teach it. The trick we use at the edge isn’t full authorization, it’s lightweight correlation. Extract the sub claim from the JWT (without verifying the signature at the edge if that costs too much — just parse), and compare it to the resource identifier in the path or body. If path_id != jwt.sub and the endpoint is known to be user-scoped, flag it. It’s not a hard block in every case — some admin roles legitimately cross users — but it gives you a high-fidelity signal that catches 99% of IDOR fuzzing we see, which typically hammers 400-600 IDs per minute per IP.

When your rules are too noisy, don't just disable them — tune systematically. The most common culprit for API false positives isn't attack signatures, it's overly strict payload validation. For example, blocking because Content-Type: application/json; charset=utf-8 didn't exactly match application/json, or because a client sent an extra X-Request-ID header with underscores. If your log-only dashboard shows >0.5% of legitimate traffic hitting a rule, that rule is broken. Pull the top 20 blocked request samples, replay them against your OpenAPI validator offline, and diff. Nine times out of ten, your spec is outdated, not the traffic. We've detailed a full workflow for this in WAF False Positive Tuning: A Systematic Methodology for Reducing Alert Noise, and for APIs you need to extend it to include WAF Rate Limiting Integration: Combining Application Rules with Traffic Controls so you’re not trying to solve a rate problem with a signature rule.

Frequently Asked Questions

Should I use the same WAF rules for my REST and GraphQL endpoints?

No. REST protection is primarily path-and-method plus JSON schema and BOLA correlation, while GraphQL protection is all about query structure — depth, alias count, field cost, and batch size. Running your REST SQLi/XSS rules against GraphQL just adds latency; a single GraphQL POST to /graphql can contain any operation. Split them into two rule groups at the edge and route by path. It'll save you 2-4ms per request and cut false positives in half.

How do I handle schema validation without killing edge latency?

Don't validate the entire payload inline. First, enforce a hard payload size cap — 8KB for most GraphQL, 32-64KB for REST — which rejects 80% of junk instantly for <0.3ms. Then validate only required fields and types via a compiled, cached schema (like a JSON Schema compiled to WASM), not via regex. Cache the compiled schema at the edge for 5 minutes and fail-open if the fetch fails. Full validation should stay at origin; the edge just needs to catch structural anomalies fast.

What's a safe starting threshold for GraphQL complexity and rate limiting?

Start conservative and observe. We typically start with max depth 12-15, max aliases 20-30, max directive count 10, and a computed complexity score of 1,000-1,500 points. Pair that with a per-user (JWT sub) rate limit of 30-60 GraphQL requests per minute, and a per-IP limit 5x that for unauthenticated traffic. Run in log mode for a week — if your p99 legitimate app traffic never exceeds 800 points, you can tighten to 1,000 with confidence.

My WAF is flagging every mobile app request as an anomaly. What now?

That's almost always a JWT or header normalization issue. Check three things: 1) Is your edge correctly parsing the Authorization bearer token after the mobile SDK adds extra whitespace or lowercases the header? 2) Did your app update to send a new enum value your OpenAPI spec doesn't know about? 3) Are you blocking based on a bot score that's scoring your mobile app as automation because it reuses TLS fingerprints? Put that specific rule to log-only, allowlist the app's JA3/JA4 fingerprint temporarily, and update your spec. Don't globally loosen the rule.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker