> ## Documentation Index
> Fetch the complete documentation index at: https://docs.esperr.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloudflare Workers

> Send Cloudflare Worker request context to Esper and apply the returned runtime decision.

This recipe assumes you are already using Cloudflare at the request boundary.
The Worker preserves Cloudflare request context, sends it to Esper, and applies
the returned runtime action before forwarding traffic.

<Info>
  **What you'll need**

  1. A deployed Esper API that your Worker can reach.
  2. An Esper tenant for the application you want to protect.
  3. An Esper API key for that tenant, stored as a Worker secret.
  4. At least one mitigation and policy in Esper.
  5. If you want managed challenge redirects, a challenge mitigation with a
     default return URL template already configured.
  6. A decision about failure behavior if Esper is unavailable: fail open or fail
     closed.
</Info>

<Info>
  **Recipe Source**

  The Worker below is the customer-facing version of the
  <a href="https://github.com/stonehedgelabs/esper/tree/main/recipes/cloudflare" target="_blank" rel="noopener noreferrer">`recipes/cloudflare`</a>
  recipe. You do not need access to the Esper repository to deploy it.
</Info>

## What this integration does

The Worker can verify Esper challenge proofs at the edge as a latency
optimization. For normal requests, it captures Cloudflare request context,
sends one synchronous runtime decision request to Esper, and then immediately
applies the returned `allow`, `challenge`, or `block` action before forwarding
traffic. Esper also verifies proof query parameters and pass cookies inside
`/runtime/mitigation`, so forwarding request query parameters and cookies is
the correctness requirement.

Canonical Worker flow:

```js theme={null}
const ESPER_API = "https://api.esperr.com/api/v1";
const CHALLENGE_PARAM = "esper_challenge_proof";
const PASS_COOKIE = "esper_challenge";

const JSON_HEADERS = {
  "content-type": "application/json",
};

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    const proofResult = await handleChallengeProof(request, url);

    if (proofResult) {
      return proofResult;
    }

    const passResult = await handleChallengePass(request);

    if (passResult) {
      return passResult;
    }

    let decision;

    try {
      decision = await getEsperDecision(request, url, env);
    } catch {
      return new Response("Esper runtime failure", { status: 502 });
    }

    return applyDecision(request, decision);
  },
};

async function handleChallengeProof(request, url) {
  const proof = url.searchParams.get(CHALLENGE_PARAM);

  if (!proof) {
    return null;
  }

  let result;

  try {
    result = await postJSON(`${ESPER_API}/challenge/proof/verify`, {
      token: proof,
    });
  } catch {
    return new Response("Challenge verification failure", { status: 502 });
  }

  if (!result.valid) {
    return new Response("Invalid challenge proof", { status: 403 });
  }

  url.searchParams.delete(CHALLENGE_PARAM);

  return new Response(null, {
    status: 302,
    headers: {
      location: url.toString(),
      "set-cookie": `${PASS_COOKIE}=${proof}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`,
    },
  });
}

async function handleChallengePass(request) {
  const pass = parseCookies(request.headers.get("cookie"))[PASS_COOKIE];

  if (!pass) {
    return null;
  }

  let result;

  try {
    result = await postJSON(`${ESPER_API}/challenge/proof/verify`, {
      token: pass,
    });
  } catch {
    return null;
  }

  if (!result.valid) {
    return null;
  }

  return fetch(request);
}

async function getEsperDecision(request, url, env) {
  return postJSON(
    `${ESPER_API}/runtime/mitigation`,
    {
      source: "cloudflare-worker",
      observed_at: new Date().toISOString(),
      idempotency_key: crypto.randomUUID(),
      return_url: request.url,
      request: {
        method: request.method,
        path: url.pathname,
        headers: collectHeaders(request),
        query_params: Object.fromEntries(url.searchParams),
        cookies: parseCookies(request.headers.get("cookie")),
        route_params: {},
      },
      fields: {
        probe: "1",
      },
    },
    {
      "x-esper-api-key": env.ESPER_API_KEY,
    },
  );
}

function applyDecision(request, decision) {
  const action = decision?.action;

  switch (action?.type) {
    case "allow":
      return fetch(request);

    case "challenge":
      return Response.redirect(action.challenge.redirect_url, 302);

    case "block": {
      const block = action.block || {};

      return new Response(block.body || "Blocked", {
        status: block.status_code || 403,
        headers: block.headers || {},
      });
    }

    default:
      return new Response("Invalid Esper decision", { status: 502 });
  }
}

async function postJSON(url, body, headers = {}) {
  const response = await fetch(url, {
    method: "POST",
    headers: {
      ...JSON_HEADERS,
      ...headers,
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    throw new Error(`Esper request failed with status ${response.status}`);
  }

  return response.json();
}

function collectHeaders(request) {
  return {
    "user-agent": request.headers.get("user-agent") || "",
    "x-forwarded-for": request.headers.get("cf-connecting-ip") || "",
    "cf-ray": request.headers.get("cf-ray") || "",
    "cf-ipcountry": request.headers.get("cf-ipcountry") || "",
  };
}

function parseCookies(cookieHeader = "") {
  if (!cookieHeader) {
    return {};
  }

  return Object.fromEntries(
    cookieHeader
      .split(";")
      .filter(Boolean)
      .map((cookie) => {
        const index = cookie.indexOf("=");

        if (index === -1) {
          return [cookie.trim(), ""];
        }

        return [cookie.slice(0, index).trim(), cookie.slice(index + 1)];
      }),
  );
}
```

## Challenge proof handling

Esper-managed challenges return visitors to the protected URL with an
`esper_challenge_proof` query parameter. The Worker may verify that token with
`POST /api/v1/challenge/proof/verify`, store the proof in a short-lived
first-party pass cookie, remove the query parameter, and redirect the browser
to the cleaned URL.

This keeps the origin and browser URL from retaining the challenge proof token.
The pass cookie lets follow-up requests during the proof TTL skip another
runtime challenge. If the Worker forwards the proof query parameter or proof
cookie in the `/runtime/mitigation` request context, Esper Cloud validates it
before policy evaluation and returns `allow` without writing another challenge
decision or challenge session.

## Runtime decision request

For normal requests, the Worker sends one `POST /api/v1/runtime/mitigation`
request. The payload preserves the method, path, selected Cloudflare headers,
query parameters, cookies, route parameters, and the original `return_url`.

The `fields.probe` value intentionally keeps the runtime payload non-empty
while your tenant-specific request extraction and policy logic reads the request
context.

## Environment variables

Required:

* `ESPER_API_KEY`

The recipe calls `https://api.esperr.com/api/v1` directly. Fork the recipe only
if you need to point at a non-production Esper API host.

## Forwarding model

For the standard setup, attach the Worker to a route on the customer's existing
Cloudflare-proxied hostname, such as:

```text theme={null}
app.example.com/api/*
```

When Esper returns `allow`, the Worker calls `fetch(request)`. Cloudflare then
forwards the original request to the hostname's configured origin. The customer
does not need to provide their Vercel origin or restate any upstream URL.

Use a custom reverse-proxy fork only for the less common deployment where the
Worker receives traffic for one hostname and must forward allowed requests to a
different origin hostname.

## Create the Worker project

Create a new Cloudflare Worker project with Cloudflare's setup tool:

```bash theme={null}
npm create cloudflare@latest -- esper-cloudflare
cd esper-cloudflare
```

When prompted, choose:

```text theme={null}
What would you like to start with?
Hello World example

Which template would you like to use?
Worker only

Which language do you want to use?
TypeScript

Do you want to add an AGENTS.md file to help AI coding tools understand Cloudflare APIs?
No

Do you want to use git for version control?
Your preference

Do you want to deploy your application?
No
```

Let the setup copy the template files, update `package.json`, and install
dependencies. Do not deploy from the setup wizard; deploy only after adding the
Esper Worker code and `ESPER_API_KEY` secret.

## Add the Esper Worker code

Replace the generated Worker entry file, usually `src/index.ts`, with the
Esper Cloudflare Worker code from this guide.

## Deploy

```bash theme={null}
npx wrangler login
npx wrangler secret put ESPER_API_KEY
```

Paste the Esper tenant API key when `wrangler secret put ESPER_API_KEY` prompts
for the secret value.

## Bind the Worker to your site

After setting `ESPER_API_KEY`, add a route to the generated Wrangler config.
For current Cloudflare Worker projects, this file is usually `wrangler.jsonc`.

For example, to run Esper on all requests to `arbi.gg`, add `routes` as a
top-level field:

```jsonc theme={null}
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "esper-cloudflare",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-02",
  "routes": [
    {
      "pattern": "arbi.gg/*",
      "zone_name": "arbi.gg"
    }
  ],
  "observability": {
    "enabled": true
  }
}
```

Use your own Cloudflare zone and hostname pattern. If you need to protect both
the apex domain and a subdomain, include both routes:

```jsonc theme={null}
"routes": [
  {
    "pattern": "example.com/*",
    "zone_name": "example.com"
  },
  {
    "pattern": "www.example.com/*",
    "zone_name": "example.com"
  }
]
```

Then deploy:

```bash theme={null}
npx wrangler deploy
```

Wrangler will deploy the Worker and create or update the configured route
binding.

## What the Worker applies

* `allow`: forward the request upstream.
* `challenge`: redirect to the Esper-managed challenge URL returned by `/api/v1/runtime/mitigation`.
* `block`: return the Esper block response from the inline `/api/v1/runtime/mitigation` decision.

## Challenge redirects

Send `return_url: request.url` with each runtime mitigation request. This is the
original URL the visitor requested, and Esper stores it on the challenge session
so a successful challenge can return the visitor to the protected page. The
mitigation's default return URL template is only a fallback when the integration
does not provide a per-request `return_url`.

<Info>
  **Customer Experience**

  The customer should not have to call Beacon, mitigation, and challenge APIs
  separately. The Worker should hide that complexity and make Esper feel like one
  deployed protection layer.
</Info>
