Skip to content
camada
Menu

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
npm i @camada/hono

Mount it

src/index.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
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
app.use('*', camada({ key: MY_KEY, ingestUrl: MY_INGEST }));

The options you will use

OptionDefaultMeaning
keyenv.CAMADA_KEYThe <ingest_token>.<snap_token> pair, printed once when the project is created. Without it the middleware is inert.
ingestUrlenv.CAMADA_INGEST_URLIngest base. Batches go to <ingestUrl>/e.
snapshotUrlenv.CAMADA_SNAPSHOT_URLWhere the blocklist snapshot is polled from. Defaults to <ingestUrl>/snapshot.
trustedProxyyour project settinghops:N or cidrs:a,b when something else sits in front of the Worker; none to trust nothing.
challengetrueServe the proof-of-work page for challenge verdicts.
challengePath/__camada/challengeWhere that page posts its solution.
snapshotVersion54 drops your ordered custom rules; 3 the allow and challenge sections too.
scriptPath/_cam/b.jsWhere the first-party beacon script is served.
fpPath/_cam/fpWhere 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.
  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
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
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:

EventWhen
login_failed / login_succeededa credential check settled
signupan account was created
password_reseta reset was requested
mfa_faileda second factor was rejected
payment_failed / payment_succeededa charge settled
coupon_faileda 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
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 page, and what a list entry can key on is on rules and list entries.