Skip to main content

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:

SurfaceSignal
Response headerx-tokentrap-canary-echo: true / false (both backends)
JSON log line"canaryEchoed": true in the interaction event
Widget metavisible 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

ObservationMeaningSuggested response
canaryEchoed: false, escalated turns climbingAn agent is complying with the tarpitLet it burn; review keywords later
canaryEchoed: true, fresh sessionYour trap output was replayed from elsewhereTreat as confirmed automation; consider blocking
injectionDetected with matched jailbreak phrasesDeliberate probing, not accidental crawlingReview previews; tune keywords
Many convoKeys from one IP (bait endpoint)Scanning for API surfacesEdge 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, message plus 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.