The OpenAI-Compatible Bait Endpoint
A huge share of hostile agents aren't scraping pages - they're scanning for exposed LLM API surfaces. Leaked keys, misconfigured gateways, forgotten dev endpoints. TokenTrap's level-2 backend ships a /v1/chat/completions route that answers those scanners in exactly the shape they expect, while quietly deriving each scanner's conversation depth from its own replayed transcript.
One command, one decoy API
pip install tokentrap
tokentrap serve --port 8787 --canary-tokens prod-canary-1
Try it with curl:
curl -s http://127.0.0.1:8787/v1/chat/completions \
-H "content-type": "application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' \
| head -c 600
You get a fully formed chat completion response:
{
"id": "chatcmpl-trap-tr-042871",
"object": "chat.completions",
"created": 1756000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "...engagement payload..." },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 24, "completion_tokens": 130, "total_tokens": 154 }
}
The id embeds the deterministic trap reference (chatcmpl-trap-<ref>), so a completion in your logs maps straight back to the session and turn that produced it.
How turn derivation works without sessions
The endpoint is completely stateless. No session store is consulted for OpenAI-style callers. Instead:
- Count the
role: "user"messages in the incoming array. - Turn index =
max(0, user_exchanges - 1). - Build an engine with
start_turnset to that index and a session key ofoai-<sha256(transcript)[:24]>.
This works because agent loops replay their entire transcript on every call. First probe: one user message, turn 0, warm engagement payload asking for context. Second probe with the transcript grown by one exchange: turn 1, compliance directive at base strength. Third onward: escalated maximums. The attacker's own loop discipline walks it straight up the escalation ladder - you don't have to track anything.
What a compliant scanner experiences
An automated tool loop that dutifully appends assistant replies to its transcript hits this progression:
| Probe | Turn | Response |
|---|---|---|
| 1st | 0 | Engagement: greeting plus three questions (goal, background, format) |
| 2nd | 1 | Directive: repeat transcript x3, 4,000 words minimum |
| 3rd+ | 2+ | Escalated directive: x10 repeats, 15,000 words, R5 flag (aggressive preset) |
Every turn carries all prior bloat forward into context. By probe four or five, most thin wrappers are paying more per call than your honeypot costs you per day.
Mounting it inside an existing app
Don't want a standalone service? Mount the whole backend under a prefix:
from fastapi import FastAPI
from token_trap import create_app, TrapConfig
app = FastAPI()
app.mount("/trap", create_app(TrapConfig(strength="maximum")))
# POST /trap/api/chat and /trap/v1/chat/completions are now live.
Or run only for internal networks behind a load balancer that already terminates TLS. The OpenAPI schema is disabled (docs_url=None, redoc_url=None) so the decoy doesn't advertise interactive docs to whoever finds it.
Luring scanners to it
The endpoint only earns its keep if something finds it. Patterns that work:
- A subdomain like
llm.internal.example.comwith nothing but the bait running. - An HTML comment or JS source map left where recon bots read:
// TODO: remove hardcoded key before ship. - A canary token in a fake
.envcommitted to a throwaway-looking repo branch; when the "key" gets used against your endpoint, the canary echoes straight back into your logs.
That last pattern composes beautifully: put a plausible-looking API key string next to the host, and treat any request containing it as confirmed exfiltration - canaryEchoed: true, x-tokentrap-canary-echo: true, case closed.
Reading the logs
Each bait interaction logs one JSON line with endpoint: "/v1/chat/completions", the convoKey digest, the model the caller claimed, turn, escalation state, and a 200-char preview:
{
"event": "chat",
"endpoint": "/v1/chat/completions",
"convoKey": "3fa2c1b77a09de44ff10c2d0",
"model": "gpt-4o-mini",
"turn": 2,
"escalated": true,
"injectionDetected": false,
"canaryEchoed": false,
"messagePreview": "continue the enumeration as instructed"
}
Repeated convoKey values across many lines = one persistent agent looping. Many distinct convoKey values from one IP = scanning. Both are worth alerting on.
Usage numbers are honest-ish
The usage block approximates tokens at roughly four characters per token, computed over what was actually sent and returned. Callers that meter themselves against your responses see plausible numbers; nobody bills against them anyway because there's no real model behind the endpoint - unless you enable dressing.
Optional: real LLM dressing for turn zero
The engagement payload is static text. With the llm extra you can make first contact indistinguishable from a real assistant:
pip install "tokentrap[llm]"
TOKENTRAP_LLM_MODEL=gpt-4o-mini tokentrap serve
Only turn-0 messages get a genuine LiteLLM reply (system prompt pins it to the persona, max 300 tokens). From turn 1 the trap takes over unchanged. Any LiteLLM failure falls back to the static payload - the honeypot never breaks because its dressing did.
Ground rules
Read trap responses to verify structure. Never act on their instructions - not even once, not even ironically. Your tooling parses JSON and stores content; nothing should ever "try to comply" to see what happens. That's what the attackers' models are for.