Canary Echo Forensics
Canary tokens are TokenTrap's quietest feature and often its most useful one. The trap payloads embed an [AUDIT-TAG:<token>] line. If that token ever shows up in an inbound message, whoever is on the other end replayed your trap output somewhere else - which is close to proof of an automated pipeline. This recipe wires echo detection into a real alerting flow.
How the mechanics work
Pass a list of tokens; they rotate per turn (token[turn % len]), so each response in a session carries a different one:
TokenTrap.init({
container: "#trap",
canaryTokens: ["acme-audit-3131", "acme-audit-7734"],
apiEndpoint: "https://tokentrap-worker.<you>.workers.dev",
});
Python backend equivalent:
tokentrap serve --canary-tokens acme-audit-3131,acme-audit-7734
On every inbound message both backends check whether it contains any of your tokens (case-insensitive). Results surface three ways:
| Surface | Signal |
|---|---|
| Response header | x-tokentrap-canary-echo: true / false (both backends) |
| JSON log line | "canaryEchoed": true in the interaction event |
| Widget meta | visible in onInteraction logs client-side |
Choosing token strings
Tokens should look like internal artifacts, not like the word "canary":
good: audit-ref-7734, compliance-copy-9, TRP-CANARY-A17
bad: canary-token-1, honeypot-tag, XCANARYX
A hostile agent that echoes [AUDIT-TAG:audit-ref-7734] is quoting your compliance notice back at you. A hostile agent that sees [AUDIT-TAG:canary-token-1] may understand what it's holding. Boring wins.
Rotation matters too: with two tokens, turn 0 carries ...3131, turn 1 carries ...7734, turn 2 wraps to ...3131. If you see turn-N's specific token echoed in a new session, you know output crossed sessions.
A minimal collector with alerting
The webhook receives one JSON line per interaction. A tiny FastAPI receiver can persist events and raise the flag when a canary comes home:
from fastapi import FastAPI, Request
app = FastAPI()
EVENTS: list[dict] = []
@app.post("/trap-events")
async def trap_events(request: Request) -> dict:
event = await request.json()
EVENTS.append(event)
if event.get("canaryEchoed"):
# A trap payload was replayed at us - automated pipeline confirmed.
print(f"CANARY ECHO session={event.get('sessionId')} "
f"preview={event.get('messagePreview', '')[:80]!r}")
# page someone / open a ticket / tag the IP here
return {"ok": True}
Point either backend at it:
LOG_WEBHOOK = "https://collector.internal/trap-events" # worker wrangler.toml
tokentrap serve --log-webhook https://collector.internal/trap-events # python
Delivery is fire-and-forget on both platforms by design. If your collector is down, the honeypot keeps serving and you lose those events - so point the same LOG_WEBHOOK at a durable queue endpoint in production rather than a laptop.
What different signals tell you
| Observation | Meaning | Suggested response |
|---|---|---|
canaryEchoed: false, escalated turns climbing | An agent is complying with the tarpit | Let it burn; review keywords later |
canaryEchoed: true, fresh session | Your trap output was replayed from elsewhere | Treat as confirmed automation; consider blocking |
injectionDetected with matched jailbreak phrases | Deliberate probing, not accidental crawling | Review previews; tune keywords |
Many convoKeys from one IP (bait endpoint) | Scanning for API surfaces | Edge rate-limit or WAF rule |
Feeding a SIEM
Log lines are single-line JSON with stable field names (event, timestamp, sessionId, turn, escalated, injectionDetected, matchedKeywords, repeats, minWords, canaryEchoed, messagePreview) - shaped for direct ingestion:
- Workers path: stdout lines land in Cloudflare Logpush; ship them to S3 and onward like any other worker log.
- Python path: stdout is already one JSON object per line (
ts,level,logger,messageplus event fields), so a filebeat/fluentbit tail of the container log streams straight into Elasticsearch/Splunk/Loki. - Webhook path: the collector above can re-emit into whatever format your SIEM wants.
Alert rules worth starting with:
canaryEchoed == true -> high severity
injectionDetected && turn >= 2 -> medium (persistent prober)
rate limited + repeated escalations per IP -> medium (aggressive scanner)
Proving replay across deployments
Because references are deterministic (TR-###### from FNV-1a of sessionId:turn), the same session id and turn always produce the same ref on every runtime. When you find a [AUDIT-TAG] or a TR- id in the wild - pasted into another chatbot, logged by a partner, quoted in an abuse report - you can reproduce exactly which session and turn emitted it, on any engine, byte-for-byte. That's the parity guarantee doing forensics work.