Cloudflare Workers
A Worker is the one place the visitor's real IP is free — Cloudflare puts it in CF-Connecting-IP, so you do not have to parse or trust an X-Forwarded-For chain. Secrets go in via wrangler.
Setup
- 1wrangler secret put LAYERCALL_KEY
- 2Read the visitor IP from the CF-Connecting-IP request header.
- 3POST it, with any email or phone from the body, to https://www.layercall.com/v1/score/user
- 4Return a 403 on block; otherwise fetch(request) through to your origin.
- 5Deploy with wrangler deploy and bind the Worker to the route you want screened.
Code
// wrangler deploy // // Runs in front of your origin. The visitor never reaches your server // when the verdict is block, so the cost of an attack stays at the edge. export default { async fetch(request, env, ctx) { if (request.method !== "POST") return fetch(request); // Cloudflare sets this itself and it cannot be spoofed by the client, // unlike X-Forwarded-For. const ip = request.headers.get("CF-Connecting-IP"); // Read the body without consuming it — the origin still needs it. const body = await request.clone().json().catch(() => ({})); let scored; try { const res = await fetch("https://www.layercall.com/v1/score/user", { method: "POST", headers: { "X-Api-Key": env.LAYERCALL_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ ip, email: body.email, phone: body.phone }), signal: AbortSignal.timeout(3000), }); scored = await res.json(); } catch { return fetch(request); // fail open to the origin } if (scored.verdict === "block") { return new Response(JSON.stringify({ error: scored.summary }), { status: 403, headers: { "Content-Type": "application/json" }, }); } // Hand the decision to the origin so it does not have to ask again. const forwarded = new Request(request); forwarded.headers.set("X-Risk-Verdict", scored.verdict); forwarded.headers.set("X-Risk-Score", String(scored.risk_score)); return fetch(forwarded); }, };
What to do with each verdict
Three outcomes, and the middle one is the one worth getting right — refusing a real customer usually costs more than reviewing them.
| Verdict | In Cloudflare Workers |
|---|---|
| allow | fetch(request) straight through. The origin never knows a check happened unless you set the header. |
| review | Forward with X-Risk-Verdict: review and let the origin decide — the edge is the wrong place to hold state about a pending review. |
| block | Return 403 at the edge. This is the whole point of doing it in a Worker: a blocked request costs you a Worker invocation instead of an origin request, a database connection and a row. |
What comes back
A real response, generated from the live API rather than written by hand. Branch on verdict; summary is a sentence written to be shown to a person, and components_checked tells you what actually went into the score.
Show the full response(POST /v1/score/user)
{ "risk_score": 65, "verdict": "review", "summary": "Needs review (65/100) — Tor exit node, commercial VPN and datacenter ASN.", "components": { "email": { "email": "test@guerrillamail.com", "normalized_email": "test@guerrillamail.com", "risk_score": 100, "verdict": "block", "status": "do_not_mail", "sub_status": "disposable", "deliverability_score": 0, "did_you_mean": null, "signals": { "syntax_valid": true, "mx_found": true, "is_disposable": true, "is_homograph": false, "is_role_account": true, "is_free_provider": false, "is_suspicious_handle": true, "is_tagged": false, "is_risky_tld": false, "is_new_domain": null, "has_spf": true, "has_dmarc": true, "has_website": true, "mailbox_exists": null, "is_catch_all": null, "mailbox_status": "unavailable", "has_digital_footprint": null }, "domain": "guerrillamail.com", "domain_age_days": null, "mx_provider": null, "mx_records": [ "mail.guerrillamail.com." ], "abuse_reports": 0, "digital_footprint": { "has_gravatar": false, "gravatar_profile_url": null, "breach_count": null, "seen_in_breach": null } }, "phone": { "parse_status": "ok", "phone": "+14155552671", "risk_score": 0, "signals": { "syntax_valid": true, "is_possible": true, "is_voip": false, "is_premium_rate": false, "is_toll_free": false, "assigned_area_code": true, "is_fictional": false }, "number": { "e164": "+14155552671", "country": "US", "national": "(415) 555-2671", "international": "+1 415 555 2671", "line_type": "fixed_line_or_mobile" }, "verdict": "allow", "abuse_reports": 0 }, "ip": { "ip": "185.220.101.1", "risk_score": 60, "signals": { "is_vpn": true, "is_proxy": true, "is_datacenter": true, "is_tor": true, "recent_abuse": false, "is_hijacked_netblock": false }, "geo": { "country": "DE", "city": "Berlin", "asn": "AS60729", "isp": "Stiftung Erneuerbare Freiheit" }, "vpn_provider": null, "hijacked_source": null, "verdict": "review", "abuse_reports": 0 } }, "components_checked": [ "email", "phone", "ip" ], "linkage": { "device_email_count": null, "email_device_count": 0, "email_ip_count": 0, "subnet_rate_1h": 2, "domain_rate_1h": 0 }, "actor": { "type": "unknown", "proven": false, "basis": "none", "operator": null, "trigger": null, "detail": "No signature and no device fingerprint. Drop fp.js on the page, or pass the agent's signed request, to get an answer here." }, "top_signals": [ "ip: tor exit node", "ip: commercial vpn", "ip: datacenter asn", "email: disposable domain", "email: role account", "email: machine-generated handle" ] }
Traps specific to Cloudflare Workers
await request.json() leaves the stream empty, and the fetch(request) that follows sends an empty body to your origin. Use request.clone() as above, or reconstruct the Request with the text you read.
If your origin trusts that header, anyone can set it and skip the check by calling your origin directly. Either delete the inbound copy in the Worker before setting yours, or lock the origin to Cloudflare IPs with Authenticated Origin Pulls.
Values under [vars] in wrangler.toml are committed to your repo and readable in the dashboard. wrangler secret put stores it encrypted and keeps it out of the file.
Questions
Does this add latency to every request?
Only to the requests you route through it — bind the Worker to your signup and checkout paths, not to /*. On those paths expect roughly 300ms, which is the API call itself; the Worker adds close to nothing.
Why not just do it at the origin?
You can. The reason to do it at the edge is that a blocked request never becomes an origin request, so an attack costs you a Worker invocation rather than a database connection — and CF-Connecting-IP is trustworthy in a way X-Forwarded-For at the origin is not.