Last spring we watched a /16 light up our edge in under 90 seconds — 40,000 IPs that had been quiet for months suddenly sending perfectly valid HTTP/2 GETs at 15 rps each. They weren't on any public blocklist and they didn't trip our volumetric thresholds. What caught them was a reputation score we'd bumped for that ASN two weeks earlier. Without that, we'd have been serving junk traffic to origin for another 20 minutes before the rate-limiters kicked in.
How IP Reputation Scoring Works at the Edge
Forget the idea that IP reputation is a simple blocklist. At CDN scale, every address gets a score on a continuum, usually 0 to 100, and that score decides what happens before the request ever hits your cache or origin. A clean residential IP that’s fetched your assets for months might sit at 5, while a host that participated in a DDoS Volumetric Attack Patterns: Identifying and Absorbing Bandwidth Floods last night could be hovering at 82 even if it’s not doing anything wrong today. We’re not judging intent, we’re estimating probability of abuse based on recent behavior, and the edge has to make that call in under a millisecond.
I've learned the hard way that a good score isn't just about counting bad requests. The most useful reputation system I've built blended four inputs: local edge observations (request rate anomalies, TLS fingerprint churn, HTTP error ratios), global threat intelligence (honeypot hits, darknet scanners), network context (ASN reputation, datacenter vs. residential classification, geo-velocity), and historical decay. An IP that sent 500 404s in a minute gets a big hit, but so does an IP from an ASN where 30% of addresses were flagged yesterday. You weight those signals differently, and you decay them fast — an abusive spike from 10 minutes ago should matter a lot more than one from 10 days ago. If you don't decay aggressively, you'll end up blocking a coffee shop's NAT gateway for a week because one laptop was infected.
For performance, we calculate this as a weighted sum with exponential decay, and we store the last update timestamp with each score. The HTTP semantics around caching and freshness still matter here — we follow guidance from the HTTP Working Group Specifications when we propagate cache-control for reputation-tagged responses, and we use patterns documented on web.dev Articles to avoid adding client-side latency while we check scores asynchronously. The scoring function itself has to be cheap enough to run on every request at 2M rps per PoP.
// Simplified scoring function run inside edge worker (Rust/WASM)
struct IpMeta { bad_req_count: u32, asn_risk: f32, last_seen: u64 }
fn threat_score(meta: &IpMeta, now: u64) -> u8 {
let hours_since = (now - meta.last_seen) as f32 / 3600.0;
// Exponential decay: half-life ~6 hours
let decay = (-0.115 * hours_since).exp();
let behavior = (meta.bad_req_count as f32 * 1.8).min(60.0);
let network = meta.asn_risk * 40.0; // 0.0 - 1.0 normalized ASN risk
let raw = (behavior + network) * decay;
raw.min(100.0) as u8
}
// Usage: if threat_score(&meta, now) > 75 { challenge }
// else if score > 50 { rate_limit_strict }
Designing a Threat Database That Survives Real Traffic
Your reputation system is only as good as the database behind it, and this is where a lot of teams shoot themselves in the foot. We started with a central Postgres cluster that all PoPs queried — it worked fine at 10k queries per second, then completely fell over the first time we had a real L7 event because every edge node was trying to read and write scores at the same time. You can't have your control plane become the bottleneck during an attack. The threat database has to live at the edge, replicated asynchronously, and be readable without a network round trip.
What we run now is a two-tier design. Hot scores (IPs seen in the last 24 hours) live in an in-memory LSM-style store on each edge server, roughly 8-12 million keys per PoP, sharded by /24 to keep prefix lookups fast. Warm data — everything older than a day but within 30 days — lives in a globally replicated KV store like RocksDB or Scylla that syncs via CRDTs every 5-10 seconds. Writes are fire-and-forget from the edge: when a node flags an IP, it bumps the local score and queues a replication message. Reads never block. If an IP isn’t in the hot store, we treat it as unknown (score ~20 if its ASN is clean) rather than waiting for a lookup. That trade-off — favoring availability over perfect consistency — has saved us countless times when a transit link is saturated.
Don't make the mistake of storing individual IPs forever. We aggregate aggressively. After 72 hours without activity, we roll that IP's history into its /24 and ASN buckets and drop the /32 key. This keeps memory flat and actually improves detection for botnets that rotate addresses inside the same prefix, which most do. We've also found that DDoS Application Layer Attack Techniques: Slowloris, RUDY, and HTTP Floods rotate much more slowly than volumetric botnets, so our L7 scores get a longer decay window than our L3/L4 scores. The lookup path has to reflect that split.
// Edge worker: non-blocking reputation check with fail-open
async function handleRequest(req, env) {
const ip = req.headers.get("cf-connecting-ip");
const prefix = ip.substring(0, ip.lastIndexOf("."));
// Hot store first - ~0.15ms p99
let score = await env.HOT_STORE.get(ip, { type: "json" });
if (score === null) {
// Check /24 aggregate, don't block on warm store
const agg = await env.HOT_STORE.get(`agg:${prefix}`, { type: "json" });
score = agg ? { value: agg.avg_score, reason: "prefix-agg" } : { value: 15, reason: "unknown" };
// Async refresh from warm store without awaiting
env.WARM_STORE.get(ip).then(warm => warm && env.HOT_STORE.put(ip, warm, {expirationTtl: 3600}));
}
if (score.value > 80) return new Response("Challenge", { status: 403 });
if (score.value > 60) return fetchWithStrictRateLimit(req);
return fetch(req);
}
Choosing Signals: Accuracy vs. Cost at Scale
Not all reputation signals are worth the same. Some are cheap to compute and highly predictive, others look impressive in a demo but melt your edge CPUs or create so many false positives you can't use them. When we added JA3 fingerprint drift as a signal, for example, detection for headless browsers went up 22%, but our per-request CPU rose by 35 microseconds — at 1.5M rps, that meant we needed two extra servers per PoP. We kept it, but we had to lower its weight and only evaluate it for IPs already scoring above 40.
The other trap is signals that are too noisy for automated blocking. We track DNS behavior because hosts that participated in DDoS DNS Amplification Defense: Stopping Reflected Attacks at CDN Scale often show up later in HTTP floods, but DNS resolver IPs are shared infrastructure. If you score a public resolver at 90 because it reflected an attack, you'll block legitimate users. So we tag resolver IPs differently and cap their maximum score at 55, using them for rate-limiting instead of challenges. The same goes for carrier-grade NAT — you'll see one IP with 10,000 legitimate users and three attackers. Prefix-level aggregation and careful caps are the only way that works.
| Reputation Signal | Score Weight (0-40) | Typical Decay Window | False Positive Rate | Edge Lookup Cost (p99) |
|---|---|---|---|---|
| Request Rate Anomaly (5m window) | 30 | 2 hours | 0.8% | 0.11 ms |
| Honeypot / Darknet Hit | 38 | 7 days | 0.05% | 0.18 ms (KV) |
| ASN / Datacenter Reputation | 15 | 14 days | 2.3% | 0.04 ms (local) |
| TLS / HTTP Fingerprint Churn | 22 | 6 hours | 1.1% | 0.35 ms |
| Origin Error Ratio (4xx/5xx burst) | 25 | 4 hours | 0.6% | 0.09 ms |
Putting Scoring in the Data Plane Without Blowing Your Latency Budget
You don't get to make a remote API call on every request — not at 2.8M RPS per PoP with a 10ms total budget. We've learned the hard way that anything over 1.5ms for a reputation lookup will push your p99 through the roof and start queuing. The trick is to keep the hot path entirely local and push updates asynchronously. Your control plane computes scores centrally every 30-60 seconds, but your data plane at the edge only does a shared-memory or eBPF map lookup. No network hop, no deserialization drama.
Our current pattern's pretty battle-tested: Tier 1 is a local Bloom filter or Cuckoo filter for super-fast negative lookups — 98.7% of clean traffic never hits the heavier store. Tier 2 is an LRU in shared memory (Lua shared dict, Varnish MSE, or an eBPF LPM trie) holding ~500k /32 and ~50k /24 aggregates per edge node, with a 0-100 integer score and a 4-byte TTL. Tier 3 is the global KV (we use a RocksDB-backed sidecar synced via Kafka) that handles the full 80-120M IP history. Syncs are delta-based and compressed — we push ~3-4 MB per minute per PoP over QUIC, and convergence is under 900ms globally. If that sync breaks, the edge keeps serving stale data for up to 5 minutes before it fails open, because failing closed during a sync glitch is how you create your own DDoS.
Don't overcomplicate the action mapping. A 0-100 score is useless if you don't tie it to deterministic enforcement. We run a simple three-band setup that we've tuned after eating a lot of false positives: 0-29 = pass, 30-59 = strict rate limit (10 req/s + priority queuing), 60-84 = JS challenge / proof-of-work, 85-100 = immediate drop at XDP/iptables. Critically, we also carve out special handling for amplification vectors. For example, a single NTP monlist probe from a score-45 IP gets escalated to an instant block — you can read more on why in our breakdown of DDoS NTP Reflection Mitigation: Blocking Monlist-Based Amplification Attacks — and the same goes for Memcached payloads where DDoS Memcached Amplification Defense: Neutralizing 51,000x Amplification Factor justifies an even lower threshold. You can't treat all L7 traffic equally when the amplification economics are that skewed.
-- OpenResty / lua-resty edge lookup (p99 < 0.4ms)
local dict = ngx.shared.reputation
local cjson = require "cjson"
local function get_reputation(ip)
-- 1. Check /32, then fallback to /24 aggregate
local score = dict:get(ip)
if not score then
local subnet = ip:match("(%d+%.%d+%.%d+)%.")
score = dict:get(subnet .. ".0/24") or 0
end
return tonumber(score) or 0
end
local ip = ngx.var.remote_addr
local score = get_reputation(ip)
local ttl = dict:ttl(ip) -- for decay observability
if score >= 85 then
ngx.exit(444) -- drop at edge, log to Kafka
elseif score >= 60 then
ngx.exec("@js_challenge")
elseif score >= 30 then
-- token bucket: 10 r/s, burst 20
local limit = require "resty.limit.req"
local lim, err = limit.new("rep_limit", 10, 20)
local delay, err = lim:incoming(ip, true)
if delay then ngx.sleep(delay) end
end
-- pass + add header for origin observability
ngx.req.set_header("X-Threat-Score", score)
When Scores Lie: Evasion, CGNAT Collisions, and Decay Tuning
Here's where most reputation systems quietly fall apart: attackers figured out rotation years ago. If you're only scoring /32s with a 72-hour TTL, a botnet with a /16 of compromised residential proxies will just cycle IPs every 90 seconds and your database will never catch up. We've seen Mirai variants rotate through ~18k IPs per hour, and cheap proxy pools now offer 2M+ daily IPs for $40. You can't win on IP alone. The fix is subnet-level reputation with exponential decay and behavior chaining. If 12 IPs in the same /24 hit your 60+ score in 10 minutes, the entire /24 gets a +25 penalty for the next 4 hours. It bumps your false positive risk by about 0.15%, but it cuts evasion success from 41% to under 6% in our tests. You also need to fuse the score with JA3/JARM, HTTP fingerprint, and request entropy — an IP with a score of 20 but a known attack fingerprint gets promoted to 65 automatically.
Troubleshooting is almost always about false positives, not false negatives. The moment you start blocking 85+ scores aggressively, you'll hear from that one enterprise customer behind a CGNAT with 4,000 users sharing a single egress IP. One infected host behind that NAT poisons the score for everyone. We mitigate this with allowlist pinning for ASNs with >1,000 detected CGNAT users, and we never auto-block a /24 that contains a Fortune 500 or major ISP's NAT pool — those go to challenge-only regardless of score. You must log every block decision with score, rule_id, subnet_agg, and decay_age to ClickHouse. When a false positive hits, you'll need to answer in under 5 minutes why it was blocked and what the score components were. If you can't, you don't have a reputation system, you've got a black box that'll get disabled after the first big customer escalation.
Finally, tune your decay — don't just expire. A flat 24-hour TTL is lazy and gets you replay attacks. We use a half-life curve: score 95 decays to 70 in 6 hours, 50 in 24 hours, and 20 in 72 hours if no new bad signals arrive. Good behavior actively accelerates decay — 500 clean requests with <0.2% error rate knock 10 points off in an hour. Bad behavior resets the clock entirely. This is especially critical for DDoS Carpet Bombing Defense: When Attackers Spread Traffic Across Your Entire Subnet scenarios, where low-and-slow traffic across 65k destination IPs won't trip traditional volumetric alerts but subnet-reputation aggregation will catch that 0.3 pps per IP pattern 12-15 minutes earlier than rate-based detection alone. Set alerts when your 85+ population grows >30% week-over-week — that's usually a decay misconfig, not a bigger botnet.
Frequently Asked Questions
How long should we keep an IP's reputation score?
It depends on the signal severity, but we've settled on a 72-hour half-life model, not a hard expiry. High-confidence scores (botnet C2, confirmed reflection source) start at 95 and take ~72 hours to decay below 20 if the IP stays clean. Low-confidence signals like single 429 spikes decay in under 6 hours. Keeping data beyond 30 days is mostly noise — less than 2% of IPs re-offend after 14 days clean — and it just bloats your KV and increases lookup latency. Store long-term history centrally for model training, but don't serve it at the edge.
What's an acceptable false positive rate for edge blocking?
For auto-drop (score 85+), keep it under 0.01% of legitimate requests. For challenges (60-84), you can tolerate 0.3-0.5% because a JS challenge is recoverable. We measure this by sampling 1% of challenge/drop traffic and validating against downstream origin success + manual review. If your 85+ block is hitting >0.05% false positives, you're almost certainly over-aggregating on /24s or not excluding CGNAT/enterprise NAT ranges. Tighten your subnet penalties and add ASN allowlists.
Can't attackers just rotate IPs to bypass reputation?
Yes, and they will. Residential proxy rotation is cheap — $0.15 per IP — so a pure /32 blocklist is useless on its own. That's why you need /24 and /16 aggregation with collective penalties, plus fingerprint correlation. When we see 15 different IPs from the same /24 and same JA3 hash hitting us in 5 minutes, we penalize the whole /24 by +25 and flag the fingerprint globally. That pushes rotation cost up 8-10x, because they have to rotate both IPs *and* tooling, not just IPs.
Does IP reputation help against carpet bombing attacks?
Absolutely, it's one of the few things that does. Carpet bombing spreads 50-100 Gbps across your entire /18 or /16 so per-IP rates look benign (often <1 pps per destination IP). Volumetric thresholds won't fire until your transit is already saturated. Reputation catches it because even though each destination gets low volume, the *source* IPs have high scores from prior abuse. If 40% of sources in a carpet bomb have a score >60, you can drop them at XDP before they even hit your conntrack table, saving ~60-70% of edge CPU during the event.