# 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:

```json
{
  "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:

```json
{
  "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](/docs/server#step-up-for-held-actions)). 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](/docs/how-it-works#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:

1. reads the session token from the `x-telltale-session-token` header, or from the SDK's `telltaleSessionToken` field on a POST form (from a copy of the body; nothing else in the body is read or sent);
2. calls `POST /api/v1/verify` with your server key, the Cloudflare fields above as `evidence.cloudflare` (only values Telltale accepts), and the request's Web Bot Auth headers as `request` so Telltale checks the signature itself;
3. passes the request to your origin with `Telltale-Decision` (allow, review, deny, `no-session` or `unavailable`), `Telltale-Decision-Id` and `Telltale-Verdict` headers.

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](/docs/verdicts): how each source affects the verdict and the decision.
- [Server API and recovery](/docs/server): the rest of verify.
- [Install with Cloudflare](/docs/cloudflare): installing the browser SDK through Zaraz (separate from this Worker).
