Skip to main content

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.

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.
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.
Recipe SourceThe Worker below is the customer-facing version of the recipes/cloudflare recipe. You do not need access to the Esper repository to deploy it.

What this integration does

The Worker first verifies any Esper challenge proof return parameter. 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. Canonical Worker flow:
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 verifies that token with POST /api/v1/challenge/proof/verify, stores the proof in a short-lived first-party pass cookie, removes the query parameter, and redirects 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 after the Worker verifies the cookie token.

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:
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:
npm create cloudflare@latest -- esper-cloudflare
cd esper-cloudflare
When prompted, choose:
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

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:
{
  "$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:
"routes": [
  {
    "pattern": "example.com/*",
    "zone_name": "example.com"
  },
  {
    "pattern": "www.example.com/*",
    "zone_name": "example.com"
  }
]
Then deploy:
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.
Customer ExperienceThe 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.