Originally published on kuryzhev.cloud
The scenario
A Cloudflare rate limit rule is often deployed with good intentions: stopping connection floods against a game server's matchmaking API. It can end up blocking the very players it was meant to protect. This is a well-known failure mode, not a rare edge case.
Rate limiting rules that count requests per IP address behave badly the moment real players share an IP. That happens constantly behind mobile carrier NAT, university networks, and office proxies. From Cloudflare's edge, a launch night spike in concurrent players can look very similar to a credential-stuffing attack or a DDoS probe against the login endpoint.
The typical failure unfolds like this:
- A Rate Limiting Rule is set to something like "block if more than 20 requests per 10 seconds from one IP."
- That threshold was tuned against a staging environment with a handful of testers.
- On launch night, several hundred players on the same carrier-grade NAT pool hit the same public IP.
- The counter trips, and Cloudflare returns 429 or a challenge.
- An entire region's worth of legitimate players gets treated as one abusive client.
Support tickets can pile up faster than the on-call engineer can read Cloudflare's analytics dashboard.
The fix is not "turn off rate limiting." Game servers are a common DDoS target, and an unprotected matchmaking endpoint is a real risk. The fix is a rate limiting configuration that distinguishes abusive traffic from legitimate shared-IP traffic, using the signals Cloudflare actually exposes for that purpose. Official reference: Cloudflare Rate Limiting Rules documentation.
Prerequisites
Before touching a rate limiting rule on a production zone, confirm the following:
-
A plan that supports the counting you need. Rate Limiting Rules exist on all plans, but the options differ:
- Free and Pro count by IP only.
- Business adds the "IP with NAT support" characteristic.
- Counting by request header, cookie, or query value requires Enterprise with Advanced Rate Limiting.
Check the plan limits table in the documentation, since periods, rule counts, and available fields also vary by plan.
- Permissions. You need API or dashboard access with permission to edit WAF and Rate Limiting rules for the zone. For the API, use a scoped API token.
- Visibility into real traffic. Use Cloudflare Logpush or the dashboard's Security Events view, so rule impact can be verified against real traffic, not guesses.
- A list of legitimate high-volume clients. This includes game server backends, health check services, and CI pipelines hitting the API. Where available, also include known proxy egress ranges used in the target region.
- A safe rollout path. Use a staging zone or a narrowly scoped canary rule. On Enterprise, you can deploy a rule with the Log action before switching to Block.
Watch out for one common gotcha: Cloudflare counts by the client IP as seen at its edge, which is whatever connected to Cloudflare.
- A load balancer behind Cloudflare, at your origin, does not change that.
- A proxy, VPN, or another CDN sitting in front of Cloudflare does. Cloudflare then sees that proxy's egress IP, which turns every player behind it into a single counted entity.
Confirm what IP Cloudflare actually sees before writing any threshold.
Step 1: Identify the actual counting key
A rate limiting rule has two parts:
- A matching expression, which decides which requests the rule applies to.
- A list of characteristics, which defines the counting key.
Characteristics can include the IP, the IP with NAT support (Business and Enterprise), and, on Enterprise with Advanced Rate Limiting, request headers, cookies, and other fields. An optional counting expression can further control which requests increment the counter.
For a game server API where many legitimate players share carrier NAT, counting purely by IP is the root cause of the lockout. A better key combines IP with something session-specific, such as a stable client-generated session header.
Rate limiting rule (dashboard equivalent):
Expression: (http.request.uri.path eq "/api/matchmaking/join")
Characteristics: cf.colo.id, ip.src, http.request.headers["x-player-session"]
# combine, not IP alone; header names are lowercase; header counting needs Enterprise ARL
Period: 10 seconds
Requests: 15
Action: Block, mitigation timeout 60 seconds
In the dashboard, the data center characteristic (cf.colo.id) is applied implicitly. Via the API, it must be listed explicitly, because counters are maintained per Cloudflare data center.
Header-based keys are client-controlled. An attacker script hammering the endpoint with rotating session headers can evade a header-keyed rule, so a second, looser IP-only rule should act as a backstop (shown in Step 2). The benefit is that fifty legitimate players behind the same NAT gateway, each with a distinct session token, no longer collapse into a single counted client.
If header counting is not available on your plan, Business plans can use "IP with NAT support." It is designed to separate distinct clients behind a shared IP.
Step 2: Separate authenticated and unauthenticated traffic
Matchmaking and lobby endpoints usually split into two kinds of traffic, and they need different tolerances:
- Pre-auth traffic (login, token refresh) is the actual attack surface for credential stuffing and deserves a tight IP-based limit.
- Post-auth traffic (join queue, submit match result) carries a session token and can afford a looser limit keyed on that token.
The call below writes all three rules to the zone's rate limiting phase entrypoint.
# PUT replaces ALL rules in the zone's http_ratelimit entrypoint ruleset.
# Fetch existing rules first with GET if you have any.
curl -X PUT \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_ratelimit/entrypoint" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"rules": [
{
"description": "pre-auth login guard",
"expression": "http.request.uri.path eq \"/api/auth/login\"",
"action": "block",
"ratelimit": {
"characteristics": ["cf.colo.id", "ip.src"],
"period": 60,
"requests_per_period": 10,
"mitigation_timeout": 60
}
},
{
"description": "post-auth matchmaking",
"expression": "http.request.uri.path eq \"/api/matchmaking/join\" and len(http.request.headers[\"authorization\"]) gt 0",
"action": "block",
"ratelimit": {
"characteristics": ["cf.colo.id", "ip.src", "http.request.headers[\"x-player-session\"]"],
"period": 10,
"requests_per_period": 20,
"mitigation_timeout": 60
}
},
{
"description": "matchmaking IP-only backstop",
"expression": "http.request.uri.path eq \"/api/matchmaking/join\"",
"action": "block",
"ratelimit": {
"characteristics": ["cf.colo.id", "ip.src"],
"period": 10,
"requests_per_period": 300,
"mitigation_timeout": 60
}
}
]
}'
Two caveats apply to this configuration:
-
Header presence is not token validation. The
authorizationcheck only confirms the header is present, not that the token is valid. Token validation still has to happen at the origin, or with API Shield JWT validation where available. - The thresholds are placeholders. Tune them against your own traffic.
The pre-auth rule uses Block with a short timeout rather than a Managed Challenge, and the reason is specific to game clients. Challenges require a browser environment to solve. A native game client or launcher calling a JSON API cannot complete one, so for those clients a challenge behaves exactly like a block, just less transparently.
Managed Challenge is a reasonable choice only when the login flow runs in a real browser or embedded webview. In that case, consider Turnstile in the login page as well.
Step 3: Deploy in Log mode first, then promote
On Enterprise, every new or modified rate limiting rule should ship with the Log action before it ever blocks anything. Log mode records what would have happened without enforcing it, which is the most reliable way to know how many real players a threshold would catch.
On other plans, the Log action is not available. Instead:
- Start with conservative thresholds.
- Scope the rule narrowly at first.
- Watch Security Events closely immediately after enabling it.
Review the logged events for at least one full peak-traffic window before promoting the rule to Block. For a game server, that usually means a weekend evening, not a Tuesday morning.
Watch out for a second gotcha: Log mode data can look clean during a quiet weekday and still produce a wave of false positives under the exact conditions the rule was built to survive, such as a launch, a patch day, or a regional event.
Step 4: Add an allowlist for known infrastructure
Game server backends often call their own APIs from a small, known set of IPs, for health checks, cross-region state sync, or admin tooling. These should bypass rate limiting through an explicit Skip rule rather than being tuned around indirectly.
Skip rules are custom rules in the http_request_firewall_custom phase, which always runs before the http_ratelimit phase.
Custom rule with Skip action (http_request_firewall_custom phase):
Expression: ip.src in {203.0.113.10 203.0.113.11 198.51.100.20}
Action: Skip -> All rate limiting rules
(optionally also: remaining custom rules; skipping WAF Managed Rules is a separate security tradeoff)
Ordering matters within each phase, but not in the way it might seem:
- Relative to rate limiting: the custom rules phase always runs first, so a Skip rule there can bypass the rate limiting phase regardless of how the rate limiting rules are ordered.
- Within the custom rules phase: rules are evaluated in list order. The Skip rule must sit above any custom rule it is meant to bypass, such as a block rule.
Only skip WAF Managed Rules for these IPs if you trust them fully, since a compromised internal host would then bypass managed protections too. Phase ordering is documented in the Ruleset Engine reference.
Verify and test
Verify the configuration change with something more concrete than "it looks fine in the dashboard." Start by confirming the counting key with a controlled test: send authenticated requests from the same IP using two distinct session headers, and confirm they count separately.
# Send 25 requests with one session header; with a limit of 15/10s,
# expect 429 responses after roughly 15 requests
for i in $(seq 1 25); do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer PLACEHOLDER_TEST_TOKEN" \
-H "X-Player-Session: session-A" \
https://api.example-game.com/api/matchmaking/join
done
# Immediately repeat with X-Player-Session: session-B from the same source IP.
# If the rule is keyed correctly, session-B starts with a fresh counter.
Then cross-check and extend the testing:
- Cross-check Security Events. Filter by rule ID to confirm which requests were counted or blocked, and why. Counting is approximate and per data center, so expect small deviations from exact thresholds.
-
Verify the allowlist. Health-check traffic from the known infrastructure IPs should never appear in the rate limit's triggered events. If it does, check the following:
- That the Skip rule's IP set matches the real egress IPs.
- That the Skip rule is enabled.
- That it lives in the custom rules phase and skips rate limiting rules.
- Load-test the pre-auth login path during a simulated peak window. Don't rely on steady-state traffic, because credential-stuffing patterns and legitimate login bursts both spike sharply and look similar in aggregate.
A Cloudflare rate limit is a blunt instrument by design, and the honest fix is rarely a single magic threshold. It is a set of rules that:
- Separate authenticated from anonymous traffic.
- Key on something more specific than a shared IP, where your plan allows it.
- Roll out cautiously before enforcement.
- Carve out known infrastructure explicitly rather than hoping it slips through.
For more infrastructure guides, see kuryzhev.cloud. It is worth treating rate limiting as a living configuration tied to launch calendars and patch schedules, not a rule written once during initial setup and forgotten until the next locked-out player ticket arrives.
Top comments (0)