Raxx · internal docs

internal · gated ↑ index

Cloudflare Rate Limiting runbook

System: Cloudflare WAF Rate Limiting — raxx.app zone Owner: operator Last incident: n/a (initial setup) Last reviewed: 2026-04-30

Overview

Cloudflare WAF rate-limit rules are zone-level rulesets that count requests per IP and apply an action (block, managed challenge, JS challenge) when a threshold is exceeded within a counting window. They live under the http_ratelimit phase on the zone.

Zone ID: f12dbb5cac57d5591a5058874498a6d1 (raxx.app)

Required API token scope

WAF rate-limit rules require Zone > WAF > Edit scope on the Cloudflare API token.

The tokens currently in vault at /MooseQuest/cloudflare/ and their documented scopes:

Vault key Documented scope WAF edit?
CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN CF Pages read + Zero Trust Access apps/policies No — 403 on http_ratelimit phase confirmed 2026-04-30
CLOUDFLARE_EDIT_DNS DNS edit on raxx.app zone Unknown — not tested
CLOUDFLARE_ACCESS_MGMT_TOKEN CF Access management No
CF_PAGES_DEPLOY_TOKEN CF Pages deploy No
CLOUDFLARE_PAGES_READ_TOKEN CF Pages read-only No

To mint a WAF-scoped token (operator action — requires CF dashboard access): 1. Go to https://dash.cloudflare.com/profile/api-tokens 2. Create Token → "Edit zone" template 3. Permissions: Zone > WAF > Edit 4. Zone resources: raxx.app (specific zone) 5. Store in Infisical at /MooseQuest/cloudflare/ as CLOUDFLARE_WAF_EDIT_TOKEN 6. Confirm it has no broader than Zone > WAF > Edit + Zone > Zone > Read (least privilege)

Current rate-limit rules

tickets.raxx.app — login brute-force defense

Field Value
Rule ID TBD — pending token provisioning (see #719)
Phase http_ratelimit
Expression (http.host eq "tickets.raxx.app" and http.request.uri.path eq "/login" and http.request.method eq "POST")
Threshold 10 requests per IP per 60 seconds
Action Block
Mitigation timeout 300 seconds (5 minutes)
Applies to POST only — GET /login is not rate-limited

How to tell it's broken

How to diagnose (in order)

  1. Check current rule state: bash ZONE_ID="f12dbb5cac57d5591a5058874498a6d1" curl -s \ -H "Authorization: Bearer ${CLOUDFLARE_WAF_EDIT_TOKEN}" \ "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/phases/http_ratelimit/entrypoint" \ | python3 -m json.tool

  2. Verify the rule expression — confirm it contains http.request.method eq "POST" and does NOT match GET.

  3. Check Cloudflare Analytics > Security > Rate Limiting to see event volume over the past hour.

  4. Check if the http_ratelimit phase ruleset ID has changed (CF does not change ruleset IDs, but if the zone was re-created this could happen).

Known failure modes

Failure mode A: token lacks WAF edit scope — rule cannot be created or modified

Symptom: - GET /zones/<zone>/rulesets/phases/http_ratelimit/entrypoint returns HTTP 403 {"message":"request is not authorized"} - Any PUT/POST to the rate-limit phase returns the same 403

Cause: The API token being used does not have Zone > WAF > Edit scope. This was the blocker on 2026-04-30 when CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN was found to cover only CF Pages read + Zero Trust Access.

Fix: 1. Mint a new CLOUDFLARE_WAF_EDIT_TOKEN in the Cloudflare dashboard (instructions above). 2. Store it in Infisical. 3. Export it: export CLOUDFLARE_WAF_EDIT_TOKEN=<value> 4. Re-run the rule deployment commands in the "Deploy the rule" section below.

Verification: GET /zones/<zone>/rulesets/phases/http_ratelimit/entrypoint returns 200.

Failure mode B: rule blocks legitimate users (threshold hit by normal usage)

Symptom: Admin reports 429 during normal login attempts.

Cause: Threshold too aggressive. 10 req/min is correct for a human but a password manager retry loop or browser autofill could hit it with rapid retries.

Fix (raise threshold to 20):

ZONE_ID="f12dbb5cac57d5591a5058874498a6d1"
RULESET_ID="<get from GET phase entrypoint>"
RULE_ID="<get from GET phase entrypoint>"

curl -s -X PATCH \
  -H "Authorization: Bearer ${CLOUDFLARE_WAF_EDIT_TOKEN}" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/${RULESET_ID}/rules/${RULE_ID}" \
  -d '{
    "ratelimit": {
      "characteristics": ["ip.src"],
      "period": 60,
      "requests_per_period": 20,
      "mitigation_timeout": 300
    }
  }'

Hard rule: Do NOT lower below 5 req/min. Do NOT extend block beyond 5 minutes (300s).

Failure mode C: rule is absent or disabled after zone change

Symptom: No rule in http_ratelimit phase; brute-force attempts not being blocked.

Fix: Re-deploy the rule using the "Deploy the rule" procedure below.

Deploy the rule

This procedure requires CLOUDFLARE_WAF_EDIT_TOKEN with Zone > WAF > Edit scope.

ZONE_ID="f12dbb5cac57d5591a5058874498a6d1"

# Step 1: Check if the http_ratelimit entrypoint ruleset already exists
GET_RESP=$(curl -s -w "\n%{http_code}" \
  -H "Authorization: Bearer ${CLOUDFLARE_WAF_EDIT_TOKEN}" \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/phases/http_ratelimit/entrypoint")
HTTP_CODE=$(echo "$GET_RESP" | tail -1)
BODY=$(echo "$GET_RESP" | head -n -1)
echo "Existing ruleset status: $HTTP_CODE"

# Step 2: Deploy the rule via PUT to the phase entrypoint
# NOTE: PUT replaces ALL rules in the ruleset. If other rules exist, include them too.
# Inspect existing rules from Step 1 before overwriting.

curl -s -X PUT \
  -H "Authorization: Bearer ${CLOUDFLARE_WAF_EDIT_TOKEN}" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/phases/http_ratelimit/entrypoint" \
  -d '{
    "rules": [
      {
        "description": "Brute-force defense: tickets.raxx.app/login POST",
        "expression": "(http.host eq \"tickets.raxx.app\" and http.request.uri.path eq \"/login\" and http.request.method eq \"POST\")",
        "action": "block",
        "ratelimit": {
          "characteristics": ["ip.src"],
          "period": 60,
          "requests_per_period": 10,
          "mitigation_timeout": 300
        }
      }
    ]
  }' | python3 -m json.tool

IMPORTANT: PUT replaces the entire ruleset. If there are other rate-limit rules on the zone, fetch the current state first, append the new rule to the existing rules array, then PUT.

Verify the rule is working

After deployment, run a rapid-fire POST test. The 11th+ request should return HTTP 429.

# Run 12 rapid POST requests to /login with dummy credentials
for i in $(seq 1 12); do
  CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "email=test@example.com&password=wrongpassword" \
    "https://tickets.raxx.app/login")
  echo "Request $i: HTTP $CODE"
done

Expected: requests 1-10 return 200 or 302 (login form response); requests 11-12 return 429.

Also verify GET is NOT blocked:

curl -s -o /dev/null -w "%{http_code}" "https://tickets.raxx.app/login"
# Expected: 200 (login page loads normally)

Remove or disable the rule

ZONE_ID="f12dbb5cac57d5591a5058874498a6d1"
# To disable (set enabled: false on the specific rule):
curl -s -X PATCH \
  -H "Authorization: Bearer ${CLOUDFLARE_WAF_EDIT_TOKEN}" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/${RULESET_ID}/rules/${RULE_ID}" \
  -d '{"enabled": false}'

# To delete the rule entirely (PATCH with DELETE verb is not supported — use PUT to replace ruleset without the rule):
# GET current rules, remove the target rule, PUT the modified array.

Escalation

Escalate to operator when: - No API token exists with WAF edit scope (must mint a new one in CF dashboard) - Rate-limiting events show a novel attack pattern not covered by the POST /login rule - Legitimate users are being blocked in significant numbers (data from CF Analytics) - Zone ID changes (should not happen, but if raxx.app zone is re-created)

CF dashboard: https://dash.cloudflare.com CF WAF Analytics: https://dash.cloudflare.com → raxx.app → Security → WAF