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.

Sign up Read the docs

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.

request.cf baseline
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.cfGET /v0/ip/{ip}
Country, region, city, coordinatesYesYes
ASN and AS organizationYesYes, plus ISP, prefix and RIR
Accuracy radius and confidenceNoaccuracy_radius_km, confidence
Hosting/datacenter flagNois_hosting
Tor exit, known VPN networks, privacy relayNois_tor, is_vpn, is_relay
Mobile carrier, satellite internetNois_mobile, is_satellite
Any IP, not only the visitor’sNoYes, 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:

Terminal
$ 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
// 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:

route()
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_hosting or is_tor on 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 when confidence is high or medium.

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 or Queue consumer
// 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.cf or 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 returns US / Mountain View with a 50 km radius and medium confidence, plus is_hosting: true and is_anycast: true, so your signup rule should send it to the challenge.

Related

Sign up Read the docs