Cloudflare Workers: Add ASN, Hosting and Tor Checks Beyond request.cf
Use request.cf as the free baseline, then add confidence, hosting, Tor exit, known VPN network and privacy relay signals, with caching and fail-open.
Free: 1,000 req/day · HTTPS · commercial OK
What request.cf gives you, and what it doesn’t
Every Worker request carries a request.cf object with the visitor’s country, region, city, coordinates, ASN and AS organization. For “which country is this visitor in?”, that’s free and instant, and you should use it.
export default {
async fetch(request) {
const cf = request.cf ?? {};
return Response.json({
country: cf.country, // "US"
city: cf.city, // "Mountain View"
asn: cf.asn, // 15169
asOrganization: cf.asOrganization,
});
},
};
What it doesn’t tell you: how precise that city is, whether the IP is a datacenter, a Tor exit or a known VPN network, and anything about IPs other than the current visitor (log lines, webhooks, stored signups). That’s the gap this guide fills.
request.cf | GET /v0/ip/{ip} | |
|---|---|---|
| Country, region, city, coordinates | Yes | Yes |
| ASN and AS organization | Yes | Yes, plus ISP, prefix and RIR |
| Accuracy radius and confidence | No | accuracy_radius_km, confidence |
| Hosting/datacenter flag | No | is_hosting |
| Tor exit, known VPN networks, privacy relay | No | is_tor, is_vpn, is_relay |
| Mobile carrier, satellite internet | No | is_mobile, is_satellite |
| Any IP, not only the visitor’s | No | Yes, plus POST /v0/bulk |
Get the client IP
Inside a Worker, the visitor’s address is in the CF-Connecting-IP header. Cloudflare sets it at the edge, so unlike a raw X-Forwarded-For, the client can’t override it.
Call /v0/ip/{ip} from the Worker
Store your key as a secret, never in wrangler.toml:
$ npx wrangler secret put NDL_API_KEY
Then look up the IP with a Bearer header, cache the result per IP in the Cache API, and fail open if the call is slow:
// src/index.js
const API = "https://api.networkdatalabs.com/v0/ip/";
const TTL = 6 * 60 * 60; // seconds
async function lookup(ip, env, ctx) {
const cache = caches.default;
const cacheKey = new Request(`https://ndl-cache.internal/ip/${ip}`);
const hit = await cache.match(cacheKey);
if (hit) return hit.json();
const res = await fetch(API + encodeURIComponent(ip), {
headers: { Authorization: `Bearer ${env.NDL_API_KEY}` },
signal: AbortSignal.timeout(500),
});
if (!res.ok) throw new Error(`lookup ${res.status}`);
const data = await res.json();
ctx.waitUntil(cache.put(cacheKey, new Response(JSON.stringify(data), {
headers: { "Cache-Control": `max-age=${TTL}`, "Content-Type": "application/json" },
})));
return data;
}
export default {
async fetch(request, env, ctx) {
const ip = request.headers.get("CF-Connecting-IP");
let intel = null;
try {
intel = await lookup(ip, env, ctx);
} catch {
// fail open: fall through to the origin
}
return route(request, intel);
},
};
The Cache API is per data center and costs nothing extra. If you want one shared cache across locations, store the JSON in Workers KV with expirationTtl instead.
Rules: challenge risky traffic, route by country
With the traits in hand, protect sensitive paths and pass the rest of the signals to your origin as headers:
const PROTECTED = ["/signup", "/login", "/checkout"];
function route(request, intel) {
const url = new URL(request.url);
const t = intel?.traits ?? {};
if (PROTECTED.includes(url.pathname) && (t.is_tor || t.is_hosting)) {
// send to a page with a challenge (Turnstile) instead of a hard block
return Response.redirect(`${url.origin}/verify?next=${url.pathname}`, 302);
}
const headers = new Headers(request.headers);
headers.set("X-IP-Country", intel?.location?.country_code ?? request.cf?.country ?? "");
headers.set("X-IP-Confidence", intel?.location?.confidence ?? "unknown");
headers.set("X-IP-Flags", Object.entries(t).filter(([, v]) => v).map(([k]) => k).join(","));
return fetch(new Request(request, { headers })); // pass to origin
}
is_hostingoris_toron signup/login: challenge (Turnstile) rather than block.is_vpn(known VPN networks, including major commercial VPN providers): let it through with a flag, and step up on payment.is_relay(privacy relay): usually ordinary users; just don’t treat the location as precise.- Country routing: use
country_code, and only use the city whenconfidenceishighormedium.
Enrich logs in batches with POST /v0/bulk
For IPs that aren’t the current visitor, such as Logpush output or a queue of signups, batch them. /v0/bulk takes up to 100 IPs, counts as one request toward the per-minute limit and one per IP toward the daily quota:
// Cron Trigger or Queue consumer: enrich up to 100 IPs per call
async function enrichBatch(ips, env) {
const res = await fetch("https://api.networkdatalabs.com/v0/bulk", {
method: "POST",
headers: {
Authorization: `Bearer ${env.NDL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ips: ips.slice(0, 100) }),
});
const { results, errors } = await res.json();
return { results, errors }; // results keyed by IP; bad IPs land in errors
}
Latency budget and fail-open
- Only call the API on the paths that need it; serve static assets straight from
request.cfor not at all. - Keep the timeout tight (around 500 ms) and always fall back to the origin on error.
- Cache per IP: repeat visitors cost zero lookups.
- Test with a US IP like
8.8.8.8: it returnsUS/ Mountain View with a 50 km radius andmediumconfidence, plusis_hosting: trueandis_anycast: true, so your signup rule should send it to the challenge.
Related
- ASN Lookup API: ASN, ISP and organization, plus
GET /v0/asn/{asn} - VPN Detection API
- Flag risky signups in Python by IP
- API Docs