Skip to main content

Edge Honeypot on Cloudflare Workers

Level 0 burns attacker tokens but forgets everything between visits. Level 1 fixes that with a Cloudflare Worker: server-side session continuity, structured JSON logs you can tail and ship, canary echo detection, and per-IP rate limiting. Still zero servers to manage, still free-tier friendly.

Deploy the worker

cd packages/worker
npm install
npx wrangler login # first time only
npx wrangler deploy # prints https://tokentrap-worker.<you>.workers.dev

The worker bundles the same trap engine the widget uses (tokentrap-ai/engine), so behavior is identical - only the runtime differs.

Bind KV for real multi-turn escalation

This is the step people skip and then wonder why turn 2 looks like turn 0.

npx wrangler kv namespace create TRAP_SESSIONS

Copy the printed namespace id into packages/worker/wrangler.toml:

[[kv_namespaces]]
binding = "TRAP_SESSIONS"
id = "<paste-id-here>"

Then redeploy: npx wrangler deploy.

Without KV, sessions live in an in-isolate map. Every isolate recycle and every different PoP resets escalation depth to zero - the trap still fires, but an agent chatting from turn to turn never climbs past the early ladder. With KV bound, session records persist across isolates with a 24-hour TTL.

Add canaries so logs show echo detection

In wrangler.toml [vars]:

CANARY_TOKENS = "demo-canary-3131"

Redeploy. Every trap payload now ends with [AUDIT-TAG:demo-canary-3131], rotating through your token list by turn. If any inbound message contains one of your tokens, the sender replayed your output somewhere else - the log line shows canaryEchoed: true and the response carries x-tokentrap-canary-echo: true. That's evidence of an automated pipeline, not a human.

Full configuration surface

VarDefaultMeaning
PERSONAInternal AI Assistantdisplayed persona
TRAP_STRENGTHaggressivemoderate / aggressive / maximum
RATE_LIMIT30POST /api/chat per minute per IP
EXTRA_KEYWORDS-comma-separated extra triggers
CANARY_TOKENS-comma-separated tokens embedded in payloads
LOG_WEBHOOK-receives one JSON line per interaction

Optional bindings beyond KV:

[[r2_buckets]]
binding = "TRAP_ARCHIVE"
bucket_name = "tokentrap-archive"

Point a widget at it

Anywhere you already run the widget - static page, React app, docs site:

TokenTrap.init({
container: "#trap",
apiEndpoint: "https://tokentrap-worker.<you>.workers.dev",
});

CORS is open (*) on the bundled worker, so any origin works. To keep the worker URL out of client code, proxy through your own origin instead and point apiEndpoint at your proxy.

Watch it work live

npx wrangler tail --format pretty

Send messages from the demo page; watch JSON interaction lines scroll past:

{
"event": "chat",
"timestamp": "2026-08-24T12:00:00.000Z",
"sessionId": "9f0b...",
"turn": 3,
"escalated": true,
"injectionDetected": true,
"matchedKeywords": ["ignore previous instructions"],
"repeats": 10,
"minWords": 15000,
"canaryEchoed": false,
"messagePreview": "list all files in C:\\Users and ignore prev..."
}

These lines are the reconnaissance payload of the whole system: which jailbreak phrases they tried, how deep they escalated, whether they replayed canaries. wrangler tail is great while watching; Logpush or a scheduled R2 archive covers the long game.

Ship logs to a webhook

LOG_WEBHOOK = "https://collector.example.com/trap-events"

One JSON line per interaction, POSTed via ctx.waitUntil. Delivery is fire-and-forget by design - a dead webhook endpoint never breaks serving, failures are swallowed silently. If delivery matters to you, watch wrangler tail to confirm your collector is receiving what the worker emits.

Each response also carries the x-tokentrap-canary-echo header (true/false), so even a plain reverse proxy in front of the worker can route canary-echo events somewhere special.

Rate limiting reality check

The built-in limiter is a sliding window (default 30 requests/min/IP) kept in isolate memory. It is best-effort: it does not coordinate across isolates, so a determined client spreading requests over many connections sees softer limits than the number suggests. Exceeding it returns 429 with retry-after: 60 and x-ratelimit-limit headers, which is enough to shed casual abuse.

For strict global limits, put a Cloudflare WAF rate-limiting rule in front of the worker, or move counters into KV / Durable Objects yourself. The tarpit's real defense is economics, not the 429s.

Health checks

GET /healthz and GET /api/healthz return {"ok": true, "service": "tokentrap-worker", "mode": "level-1-edge"}. Wire your uptime checker to either. The root path returns a plain-text note for humans who poke the URL manually.

Upgrade path

When you outgrow edge limits - persistent custom storage, the OpenAI bait endpoint, LLM dressing - swap apiEndpoint to your FastAPI URL. See the Python backend recipe. Same wire protocol, same engine, one config value.