A Cloudflare Worker runs before your origin does, in a datacenter near the visitor, with the connection metadata already parsed. That makes it the cheapest place on the internet to answer some questions and a structurally impossible place to answer others. Getting the split right is most of the design work.
We run one of these ourselves: the homepage scanner's first pass is answered at the edge, which took it from a ~200–400ms round trip to the origin down to edge latency. The source lives in the repo as cloudflare/verify-edge-worker.js, and everything below is what that exercise taught us.
What you get for free
Every request into a Worker arrives with a cf object and a set of headers that cost nothing to read:
export default {
async fetch(request, env, ctx) {
const ip = request.headers.get('CF-Connecting-IP');
const country = request.headers.get('CF-IPCountry'); // ISO-2, or XX / T1
const cf = request.cf || {};
// cf.asn, cf.asOrganization, cf.colo, cf.tlsVersion, cf.clientTcpRtt,
// cf.botManagement.score (Enterprise entitlement only)
...
}
};
Two traps in that snippet, both of which have bitten us. CF-IPCountry is not always a country: Cloudflare emits T1 for Tor and XX when it has no idea, and both render as a broken flag if you pass them to a flag component. And cf.botManagement is only populated with the Enterprise Bot Management entitlement — on other plans the property is simply absent, so code that reads cf.botManagement.score throws rather than degrading.
What the edge structurally cannot see
A Worker sees a connection. It does not see a browser. Nothing in request.cf tells you whether the client is an antidetect profile with a spoofed canvas, a headless Chrome instance, an emulator, or a person. Those are properties of the runtime on the other end, and they only become visible when something running in that runtime reports them.
This is also why the edge cannot answer the residential proxy question on its own. A residential proxy exits through a real consumer connection: the ASN is a real ISP, the country is real, the TCP round trip looks like a home line. There is nothing anomalous in the connection metadata, because the connection is genuinely ordinary.
So the useful division is:
- At the edge, before origin: known-bad networks, datacenter ASNs, Tor exits, obvious automation by user agent, and rate limiting. Cheap, no round trip, no cost to your origin.
- At the action, with a client token: device signals — automation, emulator, antidetect browser, browser tampering — and multi-account linking. These need the browser to have spoken.
Pattern 1: edge triage
The first pattern costs nothing and pays for itself on scraping traffic: refuse or challenge at the edge, before the request reaches your application, on facts you already have.
const DATACENTER_ASNS = new Set([16509, 14618, 15169, 8075, 14061, 16276]);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Only guard what is worth guarding. Static assets and the health
// check pass straight through.
if (!url.pathname.startsWith('/api/signup')) {
return fetch(request);
}
const cf = request.cf || {};
if (DATACENTER_ASNS.has(cf.asn)) {
return new Response(JSON.stringify({ error: 'blocked', reason: 'datacenter_asn' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return fetch(request);
}
};
Be careful how hard you lean on that list. A datacenter ASN is a fact about infrastructure, not a verdict about a person — corporate VPNs, mobile carrier gateways and privacy relays all live in ranges that look like hosting. Refusing signup outright on ASN alone will cost you real users. It is a reasonable trigger for a challenge or for stricter downstream handling; it is a poor trigger for a 403 on its own.
Pattern 2: verdicts at the edge, with the token
When the action already carries a client token, the Worker can get a full verdict without involving your origin at all — useful when the origin is far away and the decision is a refusal anyway.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname !== '/api/signup' || request.method !== 'POST') {
return fetch(request);
}
// Read the body once, then rebuild the request for the origin.
const raw = await request.text();
let token = null;
try { token = JSON.parse(raw).monocle; } catch (_) { /* not JSON — pass through */ }
if (token) {
const verdict = await evaluate(token, env, ctx);
if (verdict && verdict.decision === 'block') {
return new Response(JSON.stringify({ error: 'blocked', reasons: verdict.reasons }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
return fetch(new Request(request, { body: raw }));
}
};
async function evaluate(token, env, ctx) {
// Hard deadline. The edge is in front of everything; a slow vendor here
// is a slow site, so we would rather have no verdict than a late one.
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 800);
try {
const res = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.SENTINEL_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
signal: ac.signal,
});
if (!res.ok) return null;
return await res.json();
} catch (_) {
return null; // fail open: abort, network error, bad JSON
} finally {
clearTimeout(timer);
}
}
The field name is monocle. It is what the client SDK injects into the form body, and it is the only name the API reads — a plausible-looking guess such as sentinelToken silently produces a tokenless request, which comes back as a verdict built on the IP alone rather than as an error.
The key is a secret, not a constant
Worker code is not a browser bundle, but it is also not a private server: anyone with dashboard access can read the script, and it ends up in whatever repository you deploy from. Put the key in a secret and read it from env:
wrangler secret put SENTINEL_KEY
Never const SENTINEL_KEY = 'sk_live_...' at the top of the file, and never let the key reach the browser — a live key in client-side JavaScript is a key someone else is now using on your quota.
Failing open at the edge is not optional
An origin that fails closed takes down one endpoint. An edge Worker that fails closed takes down your entire site, because it sits in front of every request on its route. Three rules we hold to:
- Deadline everything. An
AbortControlleron every outbound call, in the low hundreds of milliseconds. No verdict beats a late verdict. - Catch broadly. Network error, abort, non-2xx, unparseable JSON — every one of them means "continue", not "throw".
- Have a removal plan. Ours is written into the file: delete the Workers route in the dashboard and the origin serves the path directly. A rollback you can do from a phone at 2am is worth more than a clever retry.
Also watch the quota. The Workers free tier is 100,000 requests per day for the whole account, and every request on the route counts, including the ones you pass straight through. Exceeding it errors the route rather than failing open — which is exactly the wrong behaviour for a component that sits in front of your site, and the reason a busy route belongs on a paid plan.
Use waitUntil for anything you do not need an answer from
Logging a verdict to your own analytics endpoint should not add latency to the response. ctx.waitUntil keeps the Worker alive for the side effect after the response has already gone back:
ctx.waitUntil(
fetch('https://your-collector.example/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: url.pathname, decision: verdict.decision, asn: cf.asn }),
}).catch(() => {}) // never let telemetry break the request path
);
The short version
- The edge sees the network: IP, ASN, country, TLS. Use it for cheap triage before the origin.
- The edge never sees the device. Antidetect browsers, automation and emulators need a client token.
- A datacenter ASN is a reason to add friction, not a reason to refuse outright.
- Deadline every outbound call and treat every failure as allow — a Worker that fails closed is a site outage.
- Key in
wrangler secret, read fromenv, never inline and never in the browser. - Watch the free-tier request quota: passthroughs count, and exceeding it errors the route.
Frequently Asked Questions
Can a Cloudflare Worker detect bots on its own?
Partly. A Worker sees the IP, ASN, country and TLS characteristics of the connection, which is enough to spot datacenter traffic, Tor exits and crude automation. It cannot see the browser, so headless frameworks, emulators and antidetect profiles are invisible to it unless a client-side signal is collected and passed in. Cloudflare Bot Management adds a bot score, but only on the Enterprise entitlement.
Where should I put the API key in a Cloudflare Worker?
In a secret, set with wrangler secret put SENTINEL_KEY and read as env.SENTINEL_KEY. Do not hard-code it in the script: Worker source is readable by anyone with dashboard access and usually lives in a repository. It must never reach browser-side JavaScript, where it becomes someone else’s free quota.
What happens if the fraud API is slow at the edge?
You should already have aborted. Put an AbortController deadline in the low hundreds of milliseconds on every outbound call and treat abort, network error, non-2xx and unparseable JSON as allow. A Worker sits in front of every request on its route, so failing closed there is a whole-site outage rather than one broken endpoint.
Can a Worker detect residential proxies?
Not from connection metadata alone. A residential proxy exits through a genuine consumer connection, so the ASN belongs to a real ISP and the geolocation is real. Detecting it needs network-operator intelligence about proxy infrastructure, corroborated by device signals — neither of which is present in request.cf.
Put a verdict at the edge
Free tier: 1,000 requests per hour, no card. The sandbox key runs the documented allow, review and block shapes from a Worker with no account at all.
Try Maskbreak free →