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
| Var | Default | Meaning |
|---|---|---|
PERSONA | Internal AI Assistant | displayed persona |
TRAP_STRENGTH | aggressive | moderate / aggressive / maximum |
RATE_LIMIT | 30 | POST /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.