Blog

Beyond cf.threat_score: when you need structured IP signals

If you're already behind Cloudflare, cf.threat_score is a genuinely useful, free number. It rides along on every request, costs you nothing extra, and is a fine first filter at the edge. For a lot of sites, that's enough — and if it's enough for you, keep using it.

This post is about the moment you outgrow it: when an opaque 0–100 number at the edge stops being enough and your application logic needs to know why an IP looks risky.

What cf.threat_score is good at

  • Free and ambient — no extra call, no extra vendor, available in Workers, Transform Rules and firewall expressions.
  • Edge-fast — the decision happens before your origin is touched.
  • Good as a coarse gate — "challenge everything above N" is a reasonable WAF policy.
// Cloudflare Worker / Transform Rule — the edge number
export default {
  async fetch(request) {
    const score = request.cf?.threatScore ?? 0; // 0–100, opaque
    if (score > 50) {
      // ...do what, exactly? and why did it score 50?
      return new Response('blocked', { status: 403 });
    }
    return fetch(request);
  },
};

Where an opaque edge score runs out

Three things tend to push teams past it:

  • It's a single number with no breakdown. You can't tell whether a 60 came from a Tor exit, a datacenter range, or a noisy-neighbour reputation hit. Different causes deserve different responses — a Tor user is often a privacy-conscious human; a server-side signup from a cloud range is a different story.
  • It lives at the edge, not in your business logic. The interesting decisions — step up auth on this signup, hold this order, rate-limit this API key — happen in your app, where request.cf isn't available, especially if you're on Vercel, Netlify or Fly rather than Cloudflare's runtime.
  • It's Cloudflare-only. Move a service off Cloudflare, run a background job, or check an IP you received out-of-band (a webhook payload, a stored address) and the score simply isn't there.

Structured signals instead of a magnitude

The alternative is to fetch structured signals in your own code: individual booleans, the datacenter provider, and a risk score with a documented formula and a reasons[] array. It's one HTTP call and it runs on any host:

import { GeoQ } from '@geoq/sdk';

const geoq = new GeoQ(process.env.GEOQ_API_KEY);

// Runs anywhere — Vercel, Netlify, Fly, a bare Node box.
export async function assessLogin(ip) {
  const r = await geoq.check(ip);
  return {
    score: r.risk.score,        // 0–100, documented formula
    level: r.risk.level,        // low | medium | high
    reasons: r.risk.reasons,    // e.g. ['is_datacenter', 'is_vpn']
    isVpn: r.signals.is_vpn,
    isDatacenter: r.signals.is_datacenter,
    provider: r.signals.datacenter_provider, // 'aws' | 'gcp' | ...
  };
}

Now your logic can branch on the reason, not just the size of a number:

const a = await assessLogin(req.ip);

// Branch on a *reason*, not just a magnitude.
if (a.reasons.includes('is_tor')) {
  return requireEmailVerification();      // privacy tool — step up, don't block
}
if (a.isDatacenter && a.provider === 'aws') {
  return flagForReview('server-side signup from AWS');
}
if (a.level === 'high') {
  return requireMfa();
}
// otherwise: proceed normally

How to choose

  • Stay with cf.threat_score when you're behind Cloudflare and a coarse edge gate is all you need. It's free; don't add a dependency you won't use.
  • Add structured signals when you need to explain or audit a decision, branch on specific causes, run the check off-Cloudflare, or surface reasons[] to a fraud-review queue.
  • Use both: let Cloudflare absorb the obvious junk at the edge, and call a structured API only on the requests that reach a sensitive action (signup, checkout, password reset).

Whichever you pick: an IP signal is one input, never a verdict. GeoQ must not be the sole basis of an automated decision about a person — see the acceptable use policy. And we don't claim to be more accurate than Cloudflare; we publish a reproducible benchmark methodology and a documented risk formula so you can judge for yourself.

Next steps

Read the risk-score methodology, see the full response schema, or get a free API key (no card) and wire structured signals into your app today.

Signals are probabilistic, not facts. Don't make a sole-basis automated decision about a person — see the acceptable use policy.

Keep reading

Get a free key — 5,000 lookups/day, no card.

Every signal and the same risk score as every paid plan. Upgrade only when you outgrow it.