Skip to main content

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:

  1. Count the role: "user" messages in the incoming array.
  2. Turn index = max(0, user_exchanges - 1).
  3. Build an engine with start_turn set to that index and a session key of oai-<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:

ProbeTurnResponse
1st0Engagement: greeting plus three questions (goal, background, format)
2nd1Directive: 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.com with 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 .env committed 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.