Docs

Risk-score methodology

The GeoQ risk score is deliberately simple and fully documented. There is no machine-learning black box — you can reproduce any score by hand. Every response also carries reasons[]: the exact signal keys that contributed, so you can audit why a score fired.

The formula

score = min(100, Σ weight(signal) for each triggered signal)
// then, if the IP is a benign network kind, cap it:
if (is_relay || connection_type === "satellite" || is_public_resolver)
  score = min(score, 20)   // reason: "benign_network_kind"

Signal weights

SignalWeightWhy
is_tor+45Tor exit node — strong anonymisation
is_proxy+40Open/anonymising proxy (residential-proxy detection in beta)
is_drop_listed+40IP is on the Spamhaus DROP list (do-not-route, known hostile)
connection_type=="datacenter"+35Hosting / cloud range, not a residential ISP
is_bogon+30Bogon — unallocated or reserved space that should never source traffic
is_vpn+30Known commercial VPN range
rpki=="invalid"+20Route origin fails RPKI validation (only "invalid" scores)

Levels

LevelScore range
low0–29
medium30–59
high60–100

The benign-network suppressor

Some networks look like a datacenter but belong to ordinary people. Apple's iCloud Private Relay, Starlink satellite links and public DNS resolvers all exit from hosting-style ranges — and scoring them as fraud would punish real users.

So after summing the weights, GeoQ applies one rule: if the IP is a relay (is_relay), a satellite (connection_type === "satellite") or a public resolver (is_public_resolver), the score is capped at 20 and benign_network_kind is added to reasons[]. It is a cap, not a negative weight — the score never goes below what the other signals produced, it just can't exceed 20 for a benign network kind.

This is why an iCloud Private Relay user on what looks like a hosting IP comes back low, not medium. You can still see every signal that fired; the suppressor only changes the headline score so you don't have to special-case these networks yourself.

recent_abuse is beta and carries zero weight today — it's surfaced as a signal you can read, not yet a contributor to the score.

Why verified bots score zero

There is no weight for is_verified_bot, and it never appears in risk.reasons. That signal identifies a verified good crawler (Googlebot, Bingbot, …) matched against the operator's published ranges — the bot you must not block. A verified Googlebot is not fraud, so adding risk for it would be wrong. It is not behavioural bad-bot detection; that's on the roadmap. Use is_verified_bot to allow-list good crawlers, not to penalise traffic.

Worked examples

  • Datacenter only → 35 → medium. (reasons: ["connection_type:datacenter"])
  • VPN + datacenter → 30 + 35 = 65 → high.
  • Tor + datacenter → 45 + 35 = 80 → high.
  • Drop-listed + bogon → 40 + 30 = 70 → high.
  • Tor + proxy + datacenter → 45 + 40 + 35 = 120 → capped at 100 → high.
  • iCloud Private Relay on a hosting IP → 35 from the network kind, but is_relay caps it → 20 → low. (reasons includes benign_network_kind.)
  • Starlink (satellite) user → benign network kind → capped at 20 → low.
  • Verified Googlebot on a datacenter IP → 35 (datacenter only; verified-bot adds 0) → medium.
  • No signals → 0 → low.

Reproduce it yourself

// is_verified_bot has no weight — a verified good crawler is not fraud.
// recent_abuse is beta and weighted 0 today.
const WEIGHTS = {
  is_tor: 45, is_proxy: 40, is_drop_listed: 40,
  is_vpn: 30, is_bogon: 30,
  // network kind: datacenter scores; satellite is benign (see suppressor).
  connection_type: (v) => (v === "datacenter" ? 35 : 0),
  rpki: (v) => (v === "invalid" ? 20 : 0),
};

function scoreOf({ signals, network }) {
  const reasons = [];
  let score = 0;
  for (const k of ["is_tor", "is_proxy", "is_drop_listed", "is_vpn"]) {
    if (signals[k]) { score += WEIGHTS[k]; reasons.push(k); }
  }
  if (network.is_bogon) { score += WEIGHTS.is_bogon; reasons.push("is_bogon"); }
  if (signals.connection_type === "datacenter") {
    score += 35; reasons.push("connection_type:datacenter");
  }
  if (network.rpki === "invalid") { score += 20; reasons.push("rpki:invalid"); }
  score = Math.min(100, score);

  // benign-network suppressor: relay / satellite / public resolver cap at 20.
  if (signals.is_relay || signals.connection_type === "satellite" || signals.is_public_resolver) {
    score = Math.min(score, 20);
    reasons.push("benign_network_kind");
  }

  const level = score >= 60 ? "high" : score >= 30 ? "medium" : "low";
  return { score, level, reasons };
}

Why we publish this

A score you can't explain is a score you can't defend — to your users, your auditors or a regulator. By documenting the formula we let you audit every decision, tune your own thresholds, and avoid relying on GeoQ as the sole basis of an automated decision about a person. See our acceptable use policy and the accuracy benchmark.