Edge evidence: Cloudflare and Fingerprint
Your edge and your fraud tools already reach their own conclusions about each request. You can pass those conclusions to Telltale when you verify a protected action. Telltale keeps each one as a separate, labelled source with a strength, records it with the decision, and returns it to your backend. It never mixes them into its own browser evidence, and none of them can make a session human.
Two sources are accepted:
| Source | Label in Telltale | What you send |
|---|---|---|
| Cloudflare Bot Management | Cloudflare (customer's edge) | Bot score, verified bot and its category, signed agent (Web Bot Auth checked by Cloudflare), JA4, detection IDs, JavaScript detection, static resource |
| Fingerprint Smart Signals | Fingerprint (customer's account) | Bot detection, tampering and anomaly score, virtual machine, developer tools, emulator, VPN, proxy, private window |
Who may send it
Only your backend or your own edge may send this evidence, and only with the Site's server key on POST /api/v1/verify. The browser SDK, the collector endpoints and the compatibility API's POST /v1/verify do not accept it (/v1/verify answers 400 bad_request to an evidence field).
Telltale cannot check these fields itself: your backend reports them. That is why they can only ever change your own Site's decisions, and why the strongest edge signal is still labelled as something Cloudflare checked, not something Telltale checked. Never let a browser supply them, for example by copying a request header into the body. Keep the server key out of the browser.
Send it with verify
Add an evidence object beside the session token:
{
"sessionToken": "…",
"actionId": "export_data",
"evidence": {
"cloudflare": {
"botScore": 4,
"verifiedBot": false,
"signedAgent": false,
"ja4": "t13d1516h2_8daaf6152771_02713d6af862",
"detectionIds": [33554817]
},
"fingerprint": { "bot": "bad", "botType": "headlessChrome", "virtualMachine": true }
}
}Every field is optional, but a source you name must carry at least one field. Unknown fields are refused, so a typo is a 400 that names it, not silently dropped evidence.
Cloudflare fields, with the Workers property they come from:
| Field | From | Accepted values |
|---|---|---|
botScore | request.cf.botManagement.score | 0 to 99 (1 automated, 2 to 29 likely automated, 30 to 99 likely human, 0 not scored) |
verifiedBot | request.cf.botManagement.verifiedBot | true or false |
verifiedBotCategory | request.cf.verifiedBotCategory | Cloudflare's category name, up to 64 characters |
signedAgent | request.cf.botManagement.signedAgent | true or false |
signatureAgent | the origin in the Signature-Agent header | an https origin, sent only when signedAgent is true |
ja4 | request.cf.botManagement.ja4 | a JA4 fingerprint |
detectionIds | request.cf.botManagement.detectionIds | up to 32 non-negative integers |
jsDetectionPassed | request.cf.botManagement.jsDetection.passed | true or false |
staticResource | request.cf.botManagement.staticResource | true or false |
Fingerprint fields come from the event you read with your Fingerprint secret key on your server: bot (not_detected, good or bad; notDetected is accepted too), botType, tampering, anomalyScore (0 to 1), virtualMachine, developerTools, emulator, vpn, proxy and incognito. Do not send visitorId, linkedId, requestId or IP data: Telltale does not need a device identifier for a verdict, and refuses them.
What comes back
The verify response gains edgeEvidence, which is also stored on the decision:
{
"edgeEvidence": {
"sources": [
{
"source": "cloudflare",
"label": "Cloudflare (customer's edge)",
"reportedBy": "customer-server",
"fields": { "botScore": 4, "ja4": "t13d1516h2_8daaf6152771_02713d6af862" },
"signals": [
{ "signal": "botScore", "value": 4, "strength": "vendor-heuristic", "mayLeadTo": "review", "note": "Cloudflare scored the request as likely automated (4, below 30)." },
{ "signal": "ja4", "value": "t13d…", "strength": "context", "mayLeadTo": "nothing", "note": "TLS client fingerprint. …" }
],
"mayLeadTo": "review"
}
],
"mayLeadTo": "review",
"appliedToDecision": false,
"note": "Reported by your own backend with your server key; Telltale cannot check it. …"
}
}How strong each signal is
| Signal | Strength | Most it may lead to |
|---|---|---|
Cloudflare signed agent (signedAgent: true) | edge-verified-signature: Cloudflare checked a Web Bot Auth signature from an agent it approved | deny |
| Cloudflare verified bot | edge-verified-bot: a known bot Cloudflare verified by address or reverse DNS | review |
| Cloudflare bot score 1 to 29 | vendor-heuristic | review |
Fingerprint bot bad | vendor-heuristic | review |
Fingerprint bot good | edge-verified-bot | review |
| Cloudflare bot score 30 to 99, or 0 | context | nothing |
Fingerprint bot not_detected | context | nothing |
| JA4, detection IDs, JavaScript detection, static resource | context | nothing |
| Tampering, virtual machine, developer tools, emulator, VPN, proxy, private window | context | nothing |
mayLeadTo is a ceiling, not an instruction. Edge evidence on its own may lead to deny only when Cloudflare reports a verified signed agent. Every other edge signal may lead to review (a step-up such as a passkey or MFA) at most, because a bot score or a bot-detection result is the provider's model, not proof, and assistive technology and privacy tools trip them too. Context signals never decide anything.
Nothing here is evidence of a person. A high Cloudflare score or Fingerprint's "not detected" is recorded as context only: agents that drive real browsers usually score as human. Unknown stays Unknown.
appliedToDecision says whether the evidence changed the decision. Edge evidence only tightens: it raises Allow to Hold for review, and to Deny only when mayLeadTo is deny (a Cloudflare-verified signed agent), never past the action's AI agent rule. It never lowers a decision and never changes the verdict or the attribution, so it never makes Unknown human, and it is never chargeable on its own. When it raised the decision, appliedToDecision is true and raisedFrom is the decision your rule gave; a hold it caused offers step-up with reason edge_evidence (Server API). The evidence is also kept beside the decision for your own rules and for audit. A Cloudflare signed-agent result also never becomes a Telltale-verified signature or names an agent. To have a signature checked by Telltale itself, forward the request's Web Bot Auth headers in request (the Worker below does this); Telltale then checks it against its reviewed list of signing operators.
The Cloudflare Worker
adapters/cloudflare-worker/worker.js in the Telltale release is a dependency-free Worker that sits in front of your app. On the routes you list it:
- reads the session token from the
x-telltale-session-tokenheader, or from the SDK'stelltaleSessionTokenfield on a POST form (from a copy of the body; nothing else in the body is read or sent); - calls
POST /api/v1/verifywith your server key, the Cloudflare fields above asevidence.cloudflare(only values Telltale accepts), and the request's Web Bot Auth headers asrequestso Telltale checks the signature itself; - passes the request to your origin with
Telltale-Decision(allow, review, deny,no-sessionorunavailable),Telltale-Decision-IdandTelltale-Verdictheaders.
With TELLTALE_ENFORCE=on it also applies the decision: allow passes, review answers 403 {"error":"step_up_required","decisionId":…} or redirects to your TELLTALE_STEP_UP_URL, and deny answers 403 {"error":"forbidden","decisionId":…}. The client never sees a verdict, reasons or a score.
Configure it with wrangler variables and one secret:
| Variable | Default | Meaning |
|---|---|---|
TELLTALE_ENDPOINT | required | Your Telltale server's https origin |
TELLTALE_SERVER_KEY | required, a secret | The Site's server key: wrangler secret put TELLTALE_SERVER_KEY |
TELLTALE_ROUTES | required | JSON list, e.g. [{"method":"POST","path":"/api/export","actionId":"export_data"}]. A path ending in /* is a prefix; without a method every method except GET, HEAD and OPTIONS is protected |
TELLTALE_ENFORCE | off | on applies the decision; off only adds the headers |
TELLTALE_STEP_UP_URL | none | Where review sends people, with telltale_decision and return added |
TELLTALE_MISSING_TOKEN | review | When enforcing and there is no valid session token: review, deny or pass |
TELLTALE_ON_ERROR | pass | When Telltale cannot be reached, answers late, or refuses the server key: pass (your origin decides, header unavailable) or block (503) |
TELLTALE_TIMEOUT_MS | 3000 | Deadline for the verify call (100 to 10,000) |
TELLTALE_TOKEN_HEADER | x-telltale-session-token | The header your pages put the session token in |
The Site's website addresses (Settings › Channels › Website addresses) must include the hostname the Worker serves, because the forwarded request URL is checked against them.
Trust boundary
- The server key lives only in the Worker secret and is sent only to
TELLTALE_ENDPOINT. - The Worker removes any
Telltale-*header a client sends, on every route, before it reaches your origin. Your origin can trust these headers only on traffic that came through the Worker, so lock the origin to Cloudflare (Authenticated Origin Pulls, Cloudflare Tunnel, or an origin firewall). - The decision is evidence about the session, never permission. Your origin still checks the signed-in user's own authorization before doing the work.
Fingerprint from the Worker
createWorker(config, { fingerprint }) takes an optional async function that returns the Smart Signals for a request (for example after reading the event with your Fingerprint secret key, using a request ID the page sent). Whatever it returns is sent as evidence.fingerprint; if it throws, the call goes ahead without it. Never return a visitorId.
Related
- Verdicts and authorization: how each source affects the verdict and the decision.
- Server API and recovery: the rest of verify.
- Install with Cloudflare: installing the browser SDK through Zaraz (separate from this Worker).