Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 54 additions & 86 deletions src/content/docs/en/protect-edge/cloudflare-worker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A production Prosopo Protect integration is two independent pieces working toget
- **Part 1: the Cloudflare Worker (server-side).** Sits in front of your origin, runs the verdict on every request, and blocks bad traffic before it reaches your server. This is `worker.js` plus `wrangler.toml`.
- **Part 2: the Protect JS bundle (client-side).** Loaded on every HTML page users visit. Creates the session cookie, collects the bot-signal telemetry the risk engine scores, and shows the captcha overlay when the worker returns a challenge. This is a single `<script>` tag in your HTML.

Without the worker, no request is scored. Without the bundle, no user ever gets a session cookie, so every `/api/*` and `/forms/*` request returns 401 `no-session` forever. The verify commands under Part 1 test the worker path only, so a green worker with no bundle deployed looks like it's working while every real user still fails on their first XHR.
Without the worker, no request is scored. Without the bundle, no user ever gets a session cookie, so every request on a protected path returns 401 `no-session` forever. The verify commands under Part 1 test the worker path only, so a green worker with no bundle deployed looks like it's working while every real user still fails on their first XHR.

Deploy the two parts in either order, but ship both before rollout. The sections below are organised under the same two headings.

Expand All @@ -27,32 +27,24 @@ https://packages.prosopo.io/cloud/Prosopo-Cloudflare-latest.tgz

Extract it and you get:

- `worker.js`: the worker code. Self-contained, ~60 KB. Same file for every customer; per-site config lives in `wrangler.toml`.
- `worker.js`: the worker code. Self-contained, ~105 KB. Same file for every customer; per-site config lives in `wrangler.toml`.
- `wrangler.toml`: Cloudflare deployment config template.
- `package.json`: pinned `wrangler` version so `npm install` inside the extracted directory gives you a working toolchain.
- `README.md`: quickstart mirroring this guide.

Versioned tarballs are at `https://packages.prosopo.io/cloud/Prosopo-Cloudflare-<version>.tgz` if you want to pin to a specific release.

Part 2 (client-side) is hosted by us. Either the worker injects the `<script>` tag automatically (see [Deployment modes](#deployment-modes)) or you add one `<script>` tag pointing at `https://js.protect.prosopo.io/protect.bundle.js`. Nothing to download or bundle yourself. We publish new versions to that URL; your pages pick them up on their next reload without a redeploy from you.
Part 2 (client-side) is hosted by us. You add two `<script>` tags to your pages, the second pointing at `https://js.protect.prosopo.io/protect.bundle.js`. Nothing to download or bundle yourself. We publish new versions to that URL; your pages pick them up on their next reload without a redeploy from you.

## Deployment modes

The client-side bundle can reach your users two ways. Pick one:

**Auto-inject (recommended).** Set `SITE_KEY` in `wrangler.toml` alongside `PROTECT_URL`. On every allowed HTML response the worker streams the response through Cloudflare's `HTMLRewriter` and appends the Protect bundle `<script>` tags into `<head>`. You never touch your HTML. Adding, removing, or updating pages requires no coordination — every page the worker serves gets the tags. This is the default DataDome-style install.

**Manual snippet.** Leave `SITE_KEY` out of `wrangler.toml` and paste the two-tag block into a shared header/footer template yourself (see [Part 2: Manual snippet](#2b-manual-snippet-if-you-havent-set-site_key)). Slightly more work upfront but useful when you want per-template control (e.g. omit the bundle on a specific landing page, or add extra attributes to the script tag).

`CLIENT_JWT` and `PROTECT_URL` are required in **both** modes — the worker calls the Protect API on every request regardless of injection mode.
The worker never edits your pages. Earlier releases had a `SITE_KEY` var that made the worker rewrite every HTML response to add those tags for you; that mode has been removed. Adding the tags yourself means you control where and when the bundle loads, and the worker's response path stays a straight pass-through.

## Prerequisites

- A Cloudflare account with an API token carrying:
- **Account → Workers Scripts → Edit** to upload the worker code.
- **Zone → Workers Routes → Edit** on the zone you'll bind the worker to. Only needed when you move from `workers.dev` to a production route.
- **Zone → Workers Routes → Edit** on the zone you'll bind the worker to. Always needed: the worker has to run on a route, not `workers.dev`.
- [`wrangler`](https://developers.cloudflare.com/workers/wrangler/install-and-update/) installed locally (`npm i -g wrangler`).
- A Cloudflare zone for your production domain. A `workers.dev` subdomain is fine for the initial test.
- A Cloudflare zone for your production domain, with the hostname you want protected already proxied (orange cloud) and serving from your origin. A `workers.dev` deployment cannot be tested end to end, because it has no origin behind it.
- Your Prosopo account manager has:
- Registered your site key on the Protect dashboard with your intended `defaultPathType`, endpoint rules, IP-category rules, and interstitial branding. See [Portal settings checklist](#portal-settings-checklist) below.
- Issued you a `CLIENT_JWT`, a long-lived token authenticating the worker to the Protect API.
Expand Down Expand Up @@ -113,43 +105,28 @@ compatibility_flags = ["nodejs_compat"]
[vars]
PROTECT_URL = "https://protect.<your-domain>"

# Auto-inject mode: set SITE_KEY here and the worker adds the client
# bundle <script> tags to every HTML response. Omit to run in manual
# mode (add the snippet to your HTML yourself — see Part 2).
# SITE_KEY = "<your-site-key>"

# ORIGIN_URL is where the worker forwards allowed requests. Set this
# for any deployment that fronts a real backend. Two hostnames are
# required: the worker sits on <www>, forwards to a different name
# (e.g. origin.<your-domain>, grey-clouded, pointing at your real
# server). Pointing back at <www> would loop through this worker
# forever.
ORIGIN_URL = "https://origin.<your-domain>"

# Static assets are optional. Uncomment when the worker itself serves
# files instead of forwarding to an origin (rare in production; the
# origin usually serves them). `run_worker_first` uses positive
# patterns for paths the worker runs on and `!`-prefixed patterns
# for paths served directly (Protect entirely bypassed).
# [assets]
# directory = "./public"
# binding = "ASSETS"
# run_worker_first = [
# "/*",
# "!/js/*",
# "!/css/*",
# "!/img/*",
# "!/favicon.ico",
# "!/manifest/*",
# "!/apple-touch-icon-*.png",
# "!/robots.txt",
# "!/sitemap.xml",
# ]
[secrets]
required = ["CLIENT_JWT"]

[observability]
enabled = true
```

That is the whole configuration. There is no origin URL to set: see [How the worker reaches your origin](#how-the-worker-reaches-your-origin).

`PROTECT_URL` ships as the placeholder `REPLACE_WITH_https://protect.<your-domain>`, which is deliberately not a URL. Until you replace it the worker logs an error and lets every request through unchecked, rather than failing your site — so a half-finished deploy is visible in `wrangler tail` instead of silently blocking users.

`[secrets] required` makes `wrangler deploy` refuse to deploy until `CLIENT_JWT` is set, so you cannot ship a worker that has no way to authenticate to the Protect API.

### How the worker reaches your origin

On `allow` the worker passes the request on with `fetch(request)`. On a route, Cloudflare sends that to the origin named by the hostname's own DNS record — it does not re-enter the worker. So **your existing DNS record is the origin**, and there is nothing to configure:

- `www.<your-domain>`: proxied (orange cloud), pointing at your server as it already does. The worker runs here because of the route.
- Allowed requests continue to that same server.

You do **not** need a second hostname, and there is no loop to avoid. Earlier releases took an `ORIGIN_URL` var and required a separate grey-clouded `origin.<your-domain>` record; both are gone.

Push `CLIENT_JWT` as a secret (persists across deploys) and deploy:

```bash
Expand All @@ -161,69 +138,58 @@ echo "<your-CLIENT_JWT>" | wrangler secret put CLIENT_JWT
wrangler deploy
```

The worker is now live at `https://<name>.<your-subdomain>.workers.dev`. Once the verify commands below pass, attach it to your production hostname by uncommenting the `routes` block in `wrangler.toml`:
Attach the worker to your hostname by uncommenting the `routes` block in `wrangler.toml`:

```toml
routes = [
{ pattern = "www.<your-domain>/*", zone_name = "<your-domain>" }
]
```

Then `wrangler deploy` once more. The worker now runs on every request to `www.<your-domain>`.
Then `wrangler deploy`. The worker now runs on every request to `www.<your-domain>`.

**Deploy on a route, not `workers.dev`, before testing.** A `workers.dev` URL is not in front of anything, so there is no origin for `fetch(request)` to reach: an allowed request re-enters the worker and Cloudflare returns **Error 1042**. That is expected on `workers.dev` and not a fault in your config. Only a route has an origin behind it.

Your account manager notifies you when a new `worker.js` is available. Replace the file and re-run `wrangler deploy`. Secrets persist.

### 1d. Verify the worker

```bash
WORKER=https://<name>.<your-subdomain>.workers.dev
SITE=https://www.<your-domain>

# 1. HTML: Protect should return "allow" (bootstrap-passthrough) and
# the worker forwards to your origin. Expected: 200, with
# x-prosopo-request-id in the response headers.
curl -sD - -H "Accept: text/html" -H "User-Agent: Mozilla/5.0" "$WORKER/" | head
# the worker forwards to your origin. Expected: 200, and your
# normal page body.
curl -sD - -H "Accept: text/html" -H "User-Agent: Mozilla/5.0" "$SITE/" | head

# 2. JSON, no cookie: Protect returns 401. Expected: 401,
# x-prosopo-status: no-session, x-prosopo-request-id.
curl -sD - -H "Accept: application/json" "$WORKER/api/anything" | head
# 2. JSON, no cookie, on a protected path: Protect returns 401.
# Expected: 401, x-prosopo-status: no-session, x-prosopo-request-id.
curl -sD - -H "Accept: application/json" "$SITE/api/anything" | head

# 3. POST /forms/* with Accept: text/html and no cookie: same 401.
# This closes the "spoof Accept: text/html on a POST to bypass
# session-required" bot pattern.
curl -sD - -H "Accept: text/html" -X POST "$WORKER/forms/anything" | head
curl -sD - -H "Accept: text/html" -X POST "$SITE/forms/anything" | head
```

Commands 2 and 3 only return 401 for paths your protected-path list actually covers. If they return your normal 200, check that list in the portal before assuming the worker is broken — `wrangler tail` logs `Path /… is not a protected path` when a request skips the check.

**If command 1 returns anything other than 200**, you may be hitting your own origin's bot protection, not Prosopo's. Cloudflare Bot Management, AWS WAF, and similar tools reliably block curl regardless of what headers you set, because they fingerprint TLS handshakes rather than just User-Agents. Distinguish:

- If the response has `x-prosopo-*` headers, the 4xx is from Prosopo. Check `x-prosopo-status` and `x-prosopo-decision`. See [Troubleshooting](/protect-edge/troubleshooting).
- If the response has `server: cloudflare` or `server: awselb` and no `x-prosopo-*` headers, the 4xx is from your origin's own bot protection blocking curl. Verify command 1 with a real browser instead, or temporarily point `ORIGIN_URL` at `https://httpbin.org` for the workers.dev smoke test.
- If the response has `server: cloudflare` or `server: awselb` and no `x-prosopo-*` headers, the 4xx is from your origin's own bot protection blocking curl, not from Prosopo. Verify command 1 with a real browser instead.

`X-Prosopo-Request-Id` is echoed on every response Prosopo returns and written to Protect's verdict audit log. Quote it in support tickets and we can grep straight to the exact verdict that fired. The same ID also appears as `Reference: <id>` on the blocked interstitial page so users copying that line give you a direct lookup.

Worker verified? Move to Part 2.

## Part 2: the Protect JS bundle (client-side)

**This part is mandatory.** Without the bundle no session cookie is ever set, and the worker returns 401 `no-session` on every `/api/*` or `/forms/*` call in perpetuity. How you get the bundle onto your pages depends on which [deployment mode](#deployment-modes) you picked.
**This part is mandatory.** Without the bundle no session cookie is ever set, and the worker returns 401 `no-session` on every protected path in perpetuity. The worker does not add it for you.

### 2a. Auto-inject (if you set `SITE_KEY`)
### 2a. Add the snippet

Nothing to do. The worker rewrites every allowed HTML response through Cloudflare's `HTMLRewriter` and appends these two tags at the end of `<head>`:

```html
<script>window.prosopo_config={site_key:"<your-site-key>",protect_url:"https://protect.<your-domain>"};</script>
<script src="https://js.protect.prosopo.io/protect.bundle.js" async></script>
```

The rewriter is streaming so there's no measurable latency cost. Non-HTML responses (JSON, CSS, images, XHR replies) pass through untouched.

**CSP note.** If your site sets a `Content-Security-Policy` header, add `https://js.protect.prosopo.io` to `script-src` so the injected `<script>` is allowed to load. `https://protect.<your-domain>` needs to be in `connect-src` too so the bundle's `fetch` and telemetry uploads aren't blocked.

Skip to [Verify the bundle](#2d-verify-the-bundle).

### 2b. Manual snippet (if you haven't set `SITE_KEY`)

Add this snippet to every HTML page your site serves. A shared header or footer template is the usual spot:
Add this snippet to the `<head>` of every HTML page your site serves, as early as you can. A shared header template is the usual spot:

```html
<script>
Expand All @@ -237,13 +203,17 @@ Add this snippet to every HTML page your site serves. A shared header or footer

Both tags are required. The first sets `window.prosopo_config` before the second loads; the bundle reads that config to know which Protect API to talk to. Skipping the first tag causes the bundle to fall back to the CDN's own origin (`js.protect.prosopo.io`) as its API URL, which returns 405 on the CORS preflight for `/api/protect/init` and hangs the bundle's patched `fetch`.

Either mode, once loaded the bundle:
There is no `integrity` attribute on the bundle tag, deliberately: we replace `protect.bundle.js` on every release, so a pinned hash would break your pages the next time we ship.

**CSP note.** If your site sets a `Content-Security-Policy` header, add `https://js.protect.prosopo.io` to `script-src` so the `<script>` is allowed to load. `https://protect.<your-domain>` needs to be in `connect-src` too, so the bundle's `fetch` and telemetry uploads aren't blocked.

Once loaded the bundle:

1. Calls `POST https://protect.<your-domain>/api/protect/init`.
2. Receives a `prosopo_session` cookie scoped to `.<your-domain>`.
3. Loads a per-session telemetry runtime script that collects the bot-signal data used for risk scoring.

Every subsequent request from that browser carries the cookie, and the worker looks up a real verdict on `/api/*` or `/forms/*` calls.
Every subsequent request from that browser carries the cookie, and the worker looks up a real verdict on the paths your protected-path list covers.

### 2c. Gate fast form submissions and link clicks (optional)

Expand Down Expand Up @@ -290,32 +260,30 @@ Before deploying, confirm your Portal instance shows the settings you and your a
| Enforcement | `onPending: allow`, `onChallenge: captcha`, `onError: allow` | Fail-open on transient errors, so a Protect outage never blocks legitimate users. |
| Captcha type | `frictionless` | Invisible on first attempt. Custom escalation (image challenge, PoW) can be layered on later. |
| IP category rules | Typically `abuser: block`, `tor: block`; add `proxy: block` when you don't expect residential-proxy legitimate users | Conservative default. Every category rule can be tuned per site. |
| Protected paths | `/api/**`, `/forms/**`, plus `**` as an entrypoint | The only thing that decides which paths the worker checks. Refreshed by the worker every 60 s, so changes need no redeploy. A site with no list has every path checked; an empty list checks nothing. |
| Interstitial branding | Your logo, colours, copy strings | Rendered by the edge on block/challenge, sanitized server-side, no XSS risk. |

If any of these don't match, contact your account manager before deploying. Settings drift here silently produces `401 no-session` on paths that should return `200`, or blocks IP categories you didn't intend to.

## Deploying to production behind Cloudflare

Once the workers.dev smoke tests are green, bind the worker to your production hostname by adding a route to `wrangler.toml`:
Bind the worker to your production hostname by adding a route to `wrangler.toml`:

```toml
routes = [
{ pattern = "www.<your-domain>/*", zone_name = "<your-domain>" }
]
```

For the worker's forward to reach your origin you need **two hostnames**:

- `www.<your-domain>`: orange-cloud through Cloudflare, so this worker runs.
- `origin.<your-domain>`: **grey-cloud** DNS-only, pointing at your real origin server. Set `ORIGIN_URL = "https://origin.<your-domain>"` in `wrangler.toml`.
**One hostname is all you need.** `www.<your-domain>` stays orange-clouded and pointing at your server exactly as it does today; the route makes the worker run in front of it, and allowed requests continue to that same server. See [How the worker reaches your origin](#how-the-worker-reaches-your-origin).

Pointing `ORIGIN_URL` at `www.<your-domain>` would send the forwarded request back through Cloudflare's edge, which routes it through this worker again. That's a loop.
## Excluding paths from the check

## Excluding static assets
Every request the worker checks costs a Protect API call, and hashed CSS, JS and image assets rarely need one. Which paths are checked comes from the **protected-path list in your [Prosopo dashboard](https://portal.prosopo.io/)**, not from `wrangler.toml`. The worker fetches the list from the Protect API and refreshes it every 60 seconds, so changing it needs no redeploy.

Every request through the worker costs a Protect API call. For hashed CSS, JS, and image assets that never need a bot check, you can bypass Protect entirely using Cloudflare's `run_worker_first` list. Uncomment the `[assets]` block in the `wrangler.toml` example above, drop your files under `./public`, and any path matching a `!`-prefixed pattern is served by Cloudflare's asset worker without ever invoking Protect.
Paths the list does not cover skip the session check and the Protect round-trip entirely and go straight to your origin. `wrangler tail` logs `Path /… is not a protected path` for each one, which is the quickest way to confirm the list is doing what you expect.

Rule of thumb: exclude anything you'd normally cache aggressively on a CDN. Include everything a session should be established on (HTML pages, form endpoints, XHR and JSON endpoints).
Rule of thumb: exclude anything you'd normally cache aggressively on a CDN. Include everything a session should be established on (HTML pages, form endpoints, XHR and JSON endpoints). A site with no list yet has every path checked.

## Configuring proxy, VPN and datacenter blocking

Expand Down
Loading