---
title: "Hono on Cloudflare Workers: @camada/hono SDK · camada"
description: "Install @camada/hono on Cloudflare Workers in two lines. The options, what this position sees (TLS fingerprint, ASN, country) and how it fails open."
url: https://www.camada.app/docs/hono
last_updated: 2026-10-26
---

Docs

# Hono on Cloudflare Workers

`@camada/hono` enforces your snapshot inside a Cloudflare Worker: block, allow and challenge, decided in-process, with events shipped off the response path.

## Install

shell

```sh
npm i @camada/hono
```

## Mount it

src/index.ts

```ts
import { Hono } from 'hono';
import { camada } from '@camada/hono';

const app = new Hono();

app.use('*', camada());        // key, ingest and snapshot URLs come from the Worker env
app.get('/', (c) => c.text('hello'));

export default app;
```

Workers hand environment variables to the request rather than to a process, so the middleware builds its engine from `c.env` on the first request it sees:

.dev.vars / wrangler secret

```dotenv
CAMADA_KEY=<ingest_token>.<snap_token>
CAMADA_INGEST_URL=http://localhost:8787   # dev only; defaults to production ingest
```

Put `CAMADA_KEY` in with `wrangler secret put CAMADA_KEY`, not in `wrangler.toml`. An app that reads its own configuration can pass the values in code instead:

explicit config

```ts
app.use('*', camada({ key: MY_KEY, ingestUrl: MY_INGEST }));
```

## The options you will use

| Option | Default | Meaning |
| --- | --- | --- |
| `key` | `env.CAMADA_KEY` | The <ingest_token>.<snap_token> pair, printed once when the project is created. Without it the middleware is inert. |
| `ingestUrl` | `env.CAMADA_INGEST_URL` | Ingest base. Batches go to <ingestUrl>/e. |
| `snapshotUrl` | `env.CAMADA_SNAPSHOT_URL` | Where the blocklist snapshot is polled from. Defaults to <ingestUrl>/snapshot. |
| `trustedProxy` | `your project setting` | hops:N or cidrs:a,b when something else sits in front of the Worker; none to trust nothing. |
| `challenge` | `true` | Serve the proof-of-work page for challenge verdicts. |
| `challengePath` | `/__camada/challenge` | Where that page posts its solution. |
| `snapshotVersion` | `5` | 4 drops your ordered custom rules; 3 the allow and challenge sections too. |
| `scriptPath` | `/_cam/b.js` | Where the first-party beacon script is served. |
| `fpPath` | `/_cam/fp` | Where that script posts the beacon. Keep it in the same directory as scriptPath: the script derives one from the other. |

`CAMADA_DISABLED=1` in the Worker environment switches everything off, and is checked per request.

## What it does per request

1. Refreshes the snapshot off the response path through `waitUntil` — lazily, with no interval timers, which an edge runtime does not keep alive anyway.
2. Resolves the client from `cf-connecting-ip`, falling back to `X-Forwarded-For` only under your trusted-proxy setting. A raw forwarded header is never trusted without one.
3. A blocked key gets **403** with `x-block-reason` before your handler runs. The event still ships, marked as an SDK block so it is not confused with your own 403s.
4. An allow-list entry wins over a wider block: an allow-listed address inside a blocked CIDR or ASN goes through.
5. A challenged key gets the proof-of-work page on a page load; an API call reaches your app, and its response carries `x-camada-challenge: 1` for your frontend to act on. A request whose IP cannot be resolved is let through. See [challenge](https://www.camada.app/docs/challenge).
6. Otherwise your handler runs, and the settled response ships one batched, redacted event with its real status.

## Add the beacon

Bots that never run JavaScript are the cheapest to catch. Put the tag in the `<head>` of the pages you render and the middleware does the rest:

beacon

```ts
import { camada, scriptTag } from '@camada/hono';

app.use('*', camada());
app.get('/', (c) => c.html(`<html><head>${scriptTag(c)}</head><body>…</body></html>`));
```

`scriptTag(c)` returns a `<script src="/_cam/b.js?r=…" async>` tag carrying this request's event id, so the beacon is joined to the page view it came from. The middleware serves the script at `GET /_cam/b.js` (cacheable for an hour) and relays `POST /_cam/fp` onto the event batch, stamped with the client address camada resolved — never the one the body claims. Both endpoints sit behind the verdict, so a blocked client gets 403 there too. Turn the beacon off in the project's settings and the tag becomes empty and the endpoints stand down with it.

Mounted on a prefix rather than `'*'`, the middleware never sees `/_cam/*`. Move both paths under it with `camada({ scriptPath: '/api/_cam/b.js', fpPath: '/api/_cam/fp' })`— the script derives the post path from its own URL, so the two must share a directory.

## Report what your app knows

The wire shows a `POST /login`; only your handler knows whether it failed. Tell camada from the handler:

outcomes

```ts
import { camada, track } from '@camada/hono';

app.post('/login', async (c) => {
  const ok = await signIn(c);
  if (!ok) track(c, 'login_failed', { user: email });   // await is optional: the flush rides waitUntil
  return ok ? c.redirect('/') : c.text('Invalid credentials', 401);
});
```

`track(c, event, { user })` ships the outcome joined to this request's event and session. The user identifier is hashed in-process with your ingest token before it is queued — the raw value never leaves the Worker. It never throws, and it is a no-op on a request the middleware did not run for. The event name is free-form; the analyst's rules read this vocabulary:

| Event | When |
| --- | --- |
| `login_failed / login_succeeded` | a credential check settled |
| `signup` | an account was created |
| `password_reset` | a reset was requested |
| `mfa_failed` | a second factor was rejected |
| `payment_failed / payment_succeeded` | a charge settled |
| `coupon_failed` | a promo code was rejected |

## What this position sees

`request.cf` gives a Worker the client's ASN, country and TLS fingerprint, so ASN, country and TLS-fingerprint rules genuinely enforce here — unlike a bare Node app, which sees none of them. With the beacon and `track()` this is the full in-app position: status, session, browser signals and outcomes alike. The one thing missing is raw header order, which Workers normalise away; camada knows that from the position itself and never scores its absence as evidence against a request.

## Routes to leave alone

Mount it on the paths you want covered, and keep health checks and webhook receivers out: they have no browser to challenge and must answer whatever the snapshot says.

excluding routes

```ts
const guard = camada();                 // call once and reuse: the engine is a singleton
const OPEN = new Set(['/api/health', '/api/stripe/webhook']);

app.use('/api/*', (c, next) => (OPEN.has(c.req.path) ? next() : guard(c, next)));
```

One catch when you mount on a prefix rather than on `'*'`: the middleware answers the challenge itself, on `challengePath`, so that path has to reach it. Either keep the mount at `'*'` and skip the routes you want inside the handler, or move the path under your prefix with `camada({ challengePath: '/api/__camada/challenge' })`. Otherwise a challenged browser has nowhere to post its answer and can never get through.

## It fails open

Without a key the middleware is **inert**: one log line, no requests, no enforcement, so an unprovisioned environment behaves exactly as if camada were not installed. Beyond that, a dead ingest, a corrupt snapshot or a bug in the package costs you the telemetry, never the request — camada can never turn one of your responses into a 5xx. The whole stance is on the [security](https://www.camada.app/security) page, and what a list entry can key on is on [rules and list entries](https://www.camada.app/docs/rules).

## Sitemap

[Every page on this site](https://www.camada.app/sitemap.md)
