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.cfisn'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.