On this page
- What you get for free
- What the edge structurally cannot see
- Edge triage on facts you already have
- Step 1: Add the script to the page with your form
- Step 2: Send the check from your server (the Worker)
- Step 3: Submit your form once
- Failing open at the edge is not optional
- Use waitUntil for anything you do not need an answer from
- The short version
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.
This covers what the edge sees for free, what it cannot see, and a Worker that sends the full Maskbreak check for a form post in front of your origin, in the three steps the Maskbreak console lists: add the script to the page with your form, send the check from your server (here, the Worker), and submit your form once. It starts in watch mode, where every check is logged and nothing is refused.
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. 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 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 the browser's evidence: device signals (automation, emulator, antidetect browser, browser tampering), multi-account linking, and the VPN and proxy reading of the visitor's own connection. These need the browser to have spoken.
Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.
Get an API keyEdge triage on facts you already have
The first pattern needs no API: act at the edge, before the request reaches your application, on the connection facts.
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. Everything else passes through.
if (!url.pathname.startsWith('/signup')) return fetch(request);
const cf = request.cf || {};
if (DATACENTER_ASNS.has(cf.asn)) {
// Friction, not a verdict: mark it for stricter handling downstream.
const marked = new Request(request);
marked.headers.set('X-Edge-Datacenter', '1');
return fetch(marked);
}
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 costs real users. It is a reasonable trigger for a challenge or stricter downstream handling, and a poor trigger for a 403. If your origin reads a header like that one, make sure only the Worker can set it.
Step 1: Add the script to the page with your form
The full check needs evidence from the visitor's browser. On the page that holds the form, load the script and put class="monocle-enriched" on the <form> element itself (never on an input):
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<form class="monocle-enriched" method="post" action="/signup">
<input type="email" name="email" required>
<button type="submit">Create account</button>
</form>
The script adds three hidden fields to that form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). A JavaScript frontend calls await window.Sentinel.collect() instead (check that window.Sentinel exists, and submit anyway when it does not) and sends its token, fingerprintEventId and tz in a JSON body; the Worker below reads both. If the page sends a Content-Security-Policy, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.
Step 2: Send the check from your server (the Worker)
The Worker reads the posted body once, sends the check, and forwards the original request to your origin. It sends whatever evidence arrived, including none: a check without a token or a browser check id is how the console tells you which half is missing. In watch mode, the default, it only logs.
// worker.js: in front of POST /signup on your zone
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method !== 'POST' || url.pathname !== '/signup') {
return fetch(request);
}
// Read the body once, then rebuild the request for the origin.
const raw = await request.arrayBuffer();
const fields = readFields(request.headers.get('Content-Type') || '', raw);
const verdict = await checkVisitor(fields, env);
if (env.MASKBREAK_MODE === 'enforce' && verdict?.decision === 'block') {
return new Response(JSON.stringify({ error: 'Request declined' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Watch mode, or anything short of a block in enforce mode: the origin
// runs as before. A review is the origin's call (step up, not refuse).
return fetch(new Request(request, { body: raw }));
},
};
// Form posts carry monocle / sentinel_fp / sentinel_tz; JSON from
// Sentinel.collect() carries token / fingerprintEventId / tz.
function readFields(type, raw) {
try {
const text = new TextDecoder().decode(raw);
if (type.includes('application/json')) {
const body = JSON.parse(text);
return body && typeof body === 'object' ? body : {};
}
if (type.includes('application/x-www-form-urlencoded')) {
return Object.fromEntries(new URLSearchParams(text));
}
} catch (_) { /* unreadable body: send the check without evidence */ }
return {};
}
async function checkVisitor(b, env) {
let result = null;
try {
const res = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.MASKBREAK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: b.monocle || b.token || '',
fingerprintEventId: b.sentinel_fp || b.fingerprintEventId || undefined,
tz: b.sentinel_tz || b.tz || undefined,
email: typeof b.email === 'string' ? b.email : undefined,
}),
// A deadline on every outbound call: no verdict beats a late one.
signal: AbortSignal.timeout(3000),
});
if (res.ok) result = await res.json();
else console.warn('[maskbreak] HTTP', res.status); // 401: the key, 400: the body
} catch (err) {
console.warn('[maskbreak] unavailable:', err.name); // fail open
}
// One line per check, in Workers Logs or wrangler tail. Never the key or token.
console.log('[maskbreak]', JSON.stringify({
mode: env.MASKBREAK_MODE || 'watch',
decision: result ? result.decision : null,
reasons: result ? result.reasons : null,
device_evidence: result ? result.device_evidence : null,
degraded: result ? !!result.degraded : null,
}));
return result;
}
Route it on the exact path, so the Worker never runs for your static files, and put the key in a secret. Worker code is not a browser bundle, but it is not a private server either: anyone with dashboard access can read the script, and it ends up in whatever repository you deploy from.
wrangler secret put MASKBREAK_API_KEY
MASKBREAK_MODE is an ordinary variable (vars in your Wrangler file), unset for now. Never const MASKBREAK_API_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 allowance.
Step 3: Submit your form once
Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and wrangler tail shows one [maskbreak] line. degraded: true means no network token arrived (the script was blocked, the class is missing, or the body was neither a form nor JSON); device_evidence: "not_sent" means no browser check id arrived; "ok" with degraded: false is a complete check.
After a few days of comparing those lines with the console's Events, set MASKBREAK_MODE to enforce and redeploy: the Worker then refuses a block and forwards everything else. For a stricter gate in front of a sensitive action, the Worker example on the integrations page forwards only a complete live allow in enforce mode.
Failing open at the edge is not optional
An origin that fails closed takes down one endpoint. An edge Worker that fails closed can take down everything on its route. Three rules worth holding to:
- Deadline everything. An abort signal on every outbound call. 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. 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 Workers request allowance on your plan. Every request on the route counts, including the ones you pass straight through, and an account over its daily free allowance gets errors on the route unless the route is set to fail open. 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 ?? null, asn: request.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 the browser's evidence, sent with the form.
- Read
monocle,sentinel_fpandsentinel_tz(or thecollect()names) and send all three astoken,fingerprintEventIdandtz, every time. - 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.
- Key in
wrangler secret, read fromenv, never inline and never in the browser.
Questions people ask
- 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 MASKBREAK_API_KEY and read as env.MASKBREAK_API_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 a deadline on every outbound call (the guide uses three seconds) 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 takes the whole route down rather than one handler.
- 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.
Paste it in, then watch the verdicts
The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.



