DEV Community

ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

IP Reputation WAF Rules: Fields, Thresholds, Actions

An IP reputation API is only useful in a WAF rule if you can see why a request got blocked. Cloudflare's threat score returns 0 now. AWS won't tell you what's in its list.

That leaves a gap most WAF rules quietly have. You can still block traffic by reputation, but on the two most widely deployed WAFs you either have no score to threshold against or no way to explain a block to the customer who hit it. This is a reference for closing that gap: which signals deserve a rule, what action each one justifies, and the syntax for four platforms.

TL;DR

  • cf.threat_score is deprecated and now always returns 0. Cloudflare's replacement bot score needs an Enterprise plan with Bot Management.
  • AWS WAF's AWSManagedRulesAmazonIpReputationList works, but the list contents, its changes, and its versioning are all undisclosed by design.
  • Azure's IP Reputation Rule Set sorts traffic into Bad, Good, and Unknown from Microsoft Threat Intelligence indicators, with no per-IP score either.
  • An external IP reputation API gives you per-signal fields, provider names, confidence scores, and last-seen dates, which is what makes a block auditable.
  • Precompute scores into a WAF list on a schedule. Don't put an API call in the request path unless you have a specific reason to.

Managed reputation lists are fine at catching obvious attackers and bad at everything requiring nuance. If you need to treat a corporate VPN differently from a residential proxy, or explain why a legitimate crawler got a 403, you need the underlying signals rather than a verdict.

Your WAF's built-in reputation signal is gone or opaque

Start with what each platform actually gives you, because two of the three have changed in ways the tutorials haven't caught up with.

Cloudflare removed the signal. The cf.threat_score field reference states that the score previously ran 0 to 100 and is now always 0. The Security Level docs say the same thing, and so does the analytics threat types page, which notes that the "Bad IP" category was based on that score. This isn't news, the deprecation was scheduled for September 2024, but a lot of still-circulating advice tells you to write cf.threat_score ge 15 rules. Those rules now match nothing. The replacement, cf.bot_management.score, requires Enterprise with Bot Management, so on Free through Business the Cloudflare threat score is gone with nothing behind it.

AWS keeps the signal and hides the inputs. AWSManagedRulesAmazonIpReputationList costs 25 WCUs and contains three rules: AWSManagedIPReputationList and AWSManagedReconnaissanceList both default to Block, while AWSManagedIPDDoSList defaults to Count. The data comes from Amazon's internal threat intelligence. What you can't get is the IP reputation list itself. The rule group documentation says the published information is "intended to provide you with what you need to use the rules without giving bad actors what they need to circumvent the rules," and the changelog explicitly doesn't report changes to the IP lists. No versioning, no SNS notifications. There's a reasonable security argument for that. There's also no way to audit it.

Azure does the same thing with different labels. The IP Reputation Rule Set and Bot Manager sort traffic into three buckets: Bad bots from high-confidence Microsoft Threat Intelligence IP indicators of compromise, Good bots from a verified allowlist, and Unknown bots including medium-confidence indicators. Useful defaults, no score, no per-IP evidence.

The failure mode is the same everywhere and it's documented by the vendors themselves. An AWS re:Post thread has an engineer discovering that the rule named "IP reputation" keys on bot behaviour and catches legitimate Googlebot traffic. On the Azure side, a Microsoft moderator confirmed that Bot Manager 1.1 doesn't classify ChatGPT, Claude, or Bing Copilot crawlers as Good Bots, so blocking Unknown Bots blocks them too.

The signals worth writing rules against

A single boolean is a bad input to a rule. "This IP is a proxy" forces you to pick between blocking real customers and letting threats through, because the flag covers a datacenter scraper and someone's work laptop on a corporate tunnel equally.

Anonymity signals split that. is_vpn, is_proxy, is_residential_proxy, is_relay, and is_tor describe genuinely different populations with different legitimate-use rates. Residential proxies share address space with ordinary consumers, so a hard block there hits real users. Privacy relays like iCloud Private Relay and Cloudflare WARP are ordinary consumer traffic wearing a different exit address.

Behaviour and infrastructure signals answer a different question. is_known_attacker, is_bot, and is_spam describe observed activity rather than connection type. is_cloud_provider and cloud_provider_name tell you the request came from hosting infrastructure, which matters on paths that should only see human traffic and doesn't matter at all on your API.

The evidence fields are what make any of this defensible. Provider names, confidence scores, and last-seen dates let a rule act on strength and recency instead of a bare flag. An exit node last seen this week isn't the same risk as one last seen in December, and a rule that can't tell them apart will age badly.

Here's the full security response. It's 19 fields, and this is a real flagged address rather than a documentation placeholder:

{
  "ip": "2.56.188.34",
  "security": {
    "threat_score": 80,
    "is_tor": false,
    "is_proxy": true,
    "proxy_provider_names": ["Zyte Proxy"],
    "proxy_confidence_score": 80,
    "proxy_last_seen": "2025-12-12",
    "is_residential_proxy": true,
    "is_vpn": true,
    "vpn_provider_names": ["Nord VPN"],
    "vpn_confidence_score": 80,
    "vpn_last_seen": "2026-01-19",
    "is_relay": false,
    "relay_provider_name": "",
    "is_anonymous": true,
    "is_known_attacker": true,
    "is_bot": false,
    "is_spam": false,
    "is_cloud_provider": true,
    "cloud_provider_name": "Packethub S.A."
  }
}
Enter fullscreen mode Exit fullscreen mode

Two shapes to note before you write any parsing code. The *_provider_names fields are arrays, not comma-separated strings, and they can come back empty even when the matching boolean is true. relay_provider_name and cloud_provider_name are single strings that return "" rather than null when there's no match. proxy_last_seen can also be "" on a positive is_proxy. Handle the empty cases or your rule logic will throw on the first partial match.

Signal-to-action decision matrix

This is the part no vendor publishes, because publishing it means committing to a position.

Signal What it actually tells you Action False-positive risk
is_known_attacker Observed in attack traffic Block Low
is_tor Tor exit node Block, or challenge if privacy users matter to you Low
is_proxy + high confidence + recent last-seen Datacenter or commercial proxy Block Low to moderate
is_residential_proxy Consumer ISP address routed as a proxy Challenge. Don't hard block High
is_vpn Commercial VPN Challenge, or allow depending on your business High
is_relay Privacy-routing service such as iCloud Private Relay or Cloudflare WARP Do not block on relay status alone; evaluate other risk signals Very high
is_cloud_provider Hosting or cloud address Rate limit or challenge on human-only paths Moderate
is_bot Automated agent Route by path. Allow verified crawlers Moderate
is_spam Spam source Challenge on forms and signup Moderate
is_anonymous Any of VPN, proxy, or relay Don't use as a block trigger High

The is_relay row is the one I would treat most cautiously. Relay status identifies privacy-routed traffic, not malicious intent. Do not block solely because the flag is present, but do not let it override independent evidence such as known attack activity, abusive behavior, or account-level risk.

One portability warning: challenge isn't universally available. Cloudflare has managed challenge and AWS WAF has CAPTCHA and Challenge actions, but challenge capabilities vary by platform and rule type. Cloudflare supports Managed Challenge, AWS WAF supports CAPTCHA and Challenge, and Azure Front Door Bot Manager 1.1 supports JavaScript Challenge for applicable bot rules. Where a native challenge action is unavailable, route or redirect the request to an application-controlled verification flow rather than treating “challenge” as a portable WAF action.

Threat score bands

A composite IP reputation score is the cheap version of the matrix above. It's useful as a first filter and dangerous as your only filter.

Band Action Why
80 to 100 Block or send to manual review Multiple signals agreeing
45 to 79 Additional verification, OTP or CAPTCHA Suspicious, not conclusive
20 to 44 Combine with other signals before acting Needs context to mean anything
1 to 19 Log only Low observed risk

Those bands are the vendor's own published guidance for this particular score. That matters more than it sounds, because scores don't transfer between providers. A 70 from one API and a 70 from another are unrelated numbers built from different inputs with different weightings. If you migrate providers, re-derive your thresholds against your own traffic rather than porting the number across.

Two ways to wire an IP reputation API into a rule

There are only two real architectures, and the choice mostly comes down to whether you're willing to put a network call in front of your users.

Precompute into a WAF list. A scheduled job pulls reputation data, filters it to the addresses you care about, and writes them into a native IP list. The WAF evaluates locally. Bulk lookup takes up to 50,000 addresses per POST, which collapses a large refresh into a handful of requests. Note that it collapses the request count, not the bill: bulk charges the same 2 credits for every valid address in the body.

Evaluate per request. Your edge worker or application checks the address on the way in, with a cache in front. Fresher, and it gives you the full field set at decision time instead of a precomputed yes or no.

Precompute Per request
Added latency None Cache hit is negligible, miss is a network call
Cost Predictable, one batch Scales with traffic
Freshness As stale as your interval Current
Blast radius of a bad call Whole list until next run One request
Evidence at decision time Just membership Every field

I'd start with precompute for most teams. It keeps the request path clean, it fails safe by default because a broken sync job leaves the last good list in place, and it's easier to reason about during an incident. Reach for per-request when you actually need the individual fields at decision time, for instance when a residential-proxy hit should challenge while a datacenter-proxy hit should block outright.

Scheduled bulk sync into a WAF IP list compared with per-request lookup and cache

IPGeolocation, MaxMind GeoIP2, IPQualityScore, ipinfo, and IPLocate all return reputation signals of some kind, and they differ mainly in how much evidence ships with each verdict. I'm using IPGeolocation's IP Security API for the examples because it returns provider names, confidence scores, and last-seen dates as separate fields, which is what the matrix above actually needs.

A single lookup looks like this. The dedicated security endpoint returns security data only and costs 2 credits, against 3 if you ask for security alongside geolocation on the unified endpoint:

curl -sS --max-time 2 \
  "https://api.ipgeolocation.io/v3/security?apiKey=${IPGEO_API_KEY}&ip=2.56.188.34"
Enter fullscreen mode Exit fullscreen mode

Here's the sync job. It reads a candidate list, scores it in bulk, and writes the blockable addresses into an AWS WAF IP set:

import os
import boto3
import requests
from botocore.exceptions import ClientError

API_KEY = os.environ.get("IPGEO_API_KEY")
BLOCK_AT = 80  # matches the vendor's block band; tune against your own traffic

def score_addresses(addresses):
    """Bulk-score up to 50,000 addresses per POST. Returns those worth blocking."""
    try:
        response = requests.post(
            "https://api.ipgeolocation.io/v3/security-bulk",
            params={"apiKey": API_KEY},
            json={"ips": addresses},
            timeout=(2.0, 15.0),
        )
        response.raise_for_status()
    except requests.RequestException as exc:
        # Fail open. A failed refresh must leave the existing IP set untouched
        # rather than emptying it and dropping protection entirely.
        print(f"Scoring failed, keeping current IP set: {exc}")
        return None

    blockable = []

    for item in response.json():
        security = item.get("security") or {}

        # Bogon and malformed entries return no security object.
        if not security:
            continue

        score = security.get("threat_score", 0)
        known_attacker = security.get("is_known_attacker", False)

        # Relay status alone should not trigger a block, but it must not
        # override independent high-risk evidence.
        if security.get("is_relay") and not known_attacker and score < BLOCK_AT:
            continue

        if score >= BLOCK_AT or known_attacker:
            blockable.append(f"{item['ip']}/32")

    return blockable

def update_ip_set(waf, set_id, set_name, scope, addresses):
    try:
        current = waf.get_ip_set(Id=set_id, Name=set_name, Scope=scope)
        waf.update_ip_set(
            Id=set_id,
            Name=set_name,
            Scope=scope,
            Addresses=addresses,
            LockToken=current["LockToken"],  # required; rejects concurrent writes
        )
    except ClientError as exc:
        print(f"IP set update failed: {exc}")

if __name__ == "__main__":
    candidates = ["2.56.188.34", "1.0.175.9"]
    scored = score_addresses(candidates)

    # None means the scoring request failed, so preserve the last-known-good set.
    # An empty list means the request succeeded and no addresses remain blockable,
    # so update the IP set and clear the old entries.
    if scored is not None:
        update_ip_set(
            boto3.client("wafv2", region_name="us-east-1"),
            os.environ["WAF_IP_SET_ID"],
            os.environ["WAF_IP_SET_NAME"],
            "REGIONAL",
            scored,
        )
Enter fullscreen mode Exit fullscreen mode

The fail-open behaviour is the important line. If scoring fails and you return an empty list, you've just replaced your blocklist with nothing. Returning None and skipping the write keeps the last known good state.

Two constraints on that endpoint worth knowing before you build against it. Bulk requires an API key and rejects request-origin CORS auth, so this has to run server-side. And invalid, private, or bogon entries come back as a bare message object rather than security data, which is why the loop skips anything without a security key instead of assuming every response item has one.

For the per-request path, a Cloudflare Worker with a cache in front:

const BLOCK_AT = 80;
const CACHE_TTL = 3600; // seconds; trades freshness for quota

export default {
  async fetch(request, env, ctx) {
    const requestUrl = new URL(request.url);

    // Prevent residential-proxy traffic from redirecting /verify to itself.
    if (requestUrl.pathname === "/verify") {
      return fetch(request);
    }
    // Cloudflare sets this to the real client address at the edge.
    // Behind another proxy, don't trust a client-supplied X-Forwarded-For.
    const ip = request.headers.get("CF-Connecting-IP");
    if (!ip) return fetch(request);

    const cacheKey = `rep:${ip}`;
    let verdict = await env.REPUTATION.get(cacheKey, { type: "json" });

    if (!verdict) {
      try {
        const url = `https://api.ipgeolocation.io/v3/security?apiKey=${env.IPGEO_API_KEY}&ip=${ip}`;
        const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const security = (await res.json())?.security ?? {};
                verdict = {
          score: security.threat_score ?? 0,
          relay: security.is_relay ?? false,
          residential: security.is_residential_proxy ?? false,
          knownAttacker: security.is_known_attacker ?? false,
        };
        ctx.waitUntil(
          env.REPUTATION.put(cacheKey, JSON.stringify(verdict), { expirationTtl: CACHE_TTL })
        );
      } catch (err) {
        // Fail open: a reputation lookup is not worth an outage.
        console.log(`Lookup failed for ${ip}: ${err.message}`);
        return fetch(request);
      }
    }

    if (verdict.knownAttacker || verdict.score >= BLOCK_AT) {
      return new Response("Forbidden", { status: 403 });
    }

    if (verdict.residential) {
      return Response.redirect(new URL("/verify", requestUrl), 302);
    }

    return fetch(request);
  },
};
Enter fullscreen mode Exit fullscreen mode

The residential-proxy branch redirects to a separate verification flow instead of blocking the request. The verification endpoint must implement the actual step-up control, such as Turnstile or account verification: same lookup, different action, because the populations are different.

Platform reference

Cloudflare

Custom lists live at the account level and are referenced in an expression with the in operator against $list_name:

ip.src in $reputation_blocklist
Enter fullscreen mode Exit fullscreen mode

List names must match ^[a-z0-9_]+$ and stay under 50 characters. Updating the list updates every rule referencing it, so one rule slot covers the whole blocklist regardless of size, which matters on plans with a low custom-rule quota. Custom lists also accept ASNs, matched against ip.src.asnum, if you want to act on whole networks rather than addresses.

AWS WAF

Two patterns worth knowing. The first is your own IP set, referenced with an IPSetReferenceStatement and given a priority ahead of the managed group.

The second is more useful and less known. The managed reputation rule group adds labels to every request it evaluates, and those labels are available to AWS WAF rules running after it. So you can override the group's action to Count, then write your own rule keying on the label plus your own conditions:

awswaf:managed:aws:amazon-ip-list:AWSManagedIPReputationList
Enter fullscreen mode Exit fullscreen mode

That turns an opaque block into a signal you combine with your own data. It's the closest thing AWS offers to tuning the managed list.

If a managed rule is blocking traffic you need, the documented fix is a scope-down statement: set the rule group's scope of inspection to "only requests that match a scope-down statement," then invert it with a NOT against your allowlist IP set.

Azure WAF

Custom rules are processed before managed WAF rules, which is the precedence you want for a bring-your-own signal. Rules run in priority order and combine a match condition with an action. Use an IPMatch condition against your synchronized address list. Standard actions include Allow, Block, Log, and Redirect, while Azure Front Door Premium also supports JavaScript Challenge in supported scenarios.

NGINX and ModSecurity

Self-hosted has no built-in reputation data at all, which makes it the clearest case. You supply everything, so there's nothing opaque.

For NGINX, generate a geo block from your scored data and include it:

geo $ip_reputation_block {
    default          0;
    include          /etc/nginx/conf.d/reputation.map;  # regenerate on each sync
}

server {
    if ($ip_reputation_block) {
        return 403;
    }
}
Enter fullscreen mode Exit fullscreen mode

For ModSecurity or Coraza, match the address against a generated file:

SecRule REMOTE_ADDR "@ipMatchFromFile /etc/modsecurity/reputation.txt" \
    "id:1001,phase:1,deny,status:403,log,msg:'IP reputation block'"
Enter fullscreen mode Exit fullscreen mode

If you're going the self-hosted route at volume, the downloadable security database is a better fit than the API. It ships the same fields as CSV keyed by address range, so you can regenerate these files from a local file instead of making network calls. One quirk to plan for: the database serialises booleans as strings, so is_proxy arrives as "true" rather than true. Cast before you branch on it.

What breaks this in production

The address you're scoring is the wrong address. AWS documents that its IP reputation rules use the source address from the web request origin, which behind a proxy or load balancer is the last proxy rather than the client. Same trust-proxy problem you have in application middleware, one layer further out. Verify what your WAF is actually seeing before you tune anything.

Fail-open versus fail-closed, decided on purpose. Both code paths above fail open. That's the right default for a reputation check, which is a risk signal rather than an authentication boundary. If you fail closed, say so in the runbook, because the first API timeout will otherwise look like an outage nobody can explain.

Cache TTL fights freshness. A one-hour TTL on a residential proxy address is probably fine. On a fast-rotating datacenter proxy it's stale. Consider a shorter TTL for high-score addresses and a longer one for clean ones.

Do the credit arithmetic before you commit. The Starter plan is $19 a month for 150,000 credits, and a dedicated security lookup costs 2 credits, so that's 75,000 lookups, not 150,000. Combined with geolocation on the unified endpoint it's 3 credits and 50,000 lookups. Bulk is billed per valid address at the same rate, so one maximum-size request of 50,000 addresses costs 100,000 credits, which is two thirds of a Starter plan in a single call. Size your refresh interval against that, not against the request count. The X-Credits-Charged header gives you the exact charge, and bogon, private, and malformed addresses aren't billed at all, which makes the API safe to point at unfiltered log data.

Shared addresses punish blunt rules. Carrier-grade NAT and mobile carrier ranges put thousands of unrelated users behind one address. A block there is a block on all of them. This is the strongest argument for challenge over block on the high-false-positive rows in the matrix.

IPv6 needs its own pass. Reputation coverage on IPv6 is thinner than IPv4 across every provider, and some WAF list features are IPv4-only. Check before you assume parity.

Start every new rule in Count or Log mode and leave it there long enough to see real traffic through it. Compare what it would have blocked against addresses you know are legitimate, then promote it. The threshold you ship should be the one your own traffic justified, not the one in this table.

Top comments (0)