Claude Code is steganographically marking requests
Claude Code is steganographically marking requests
A request can look like this in your gateway log:
{
"role": "user",
"content": "Fix the failing authentication test."
}
Yet the actual string may be 47 bytes longer than the visible text. The extra bytes can be Unicode characters that render with zero width or modify the preceding character without producing an obvious visual difference. Your terminal, JSON viewer, and observability dashboard may all display the same sentence while the model receives a distinguishable request.
That is the important engineering fact behind Claude Code’s prompt marking: request identity can be carried inside apparently ordinary prompt text rather than an explicit field such as:
{"client": "claude-code"}
This is steganography in the practical sense. Information is hidden in the representation of the prompt, not necessarily encrypted, and normal visual inspection will miss it.
What the Technique Actually Does
Unicode gives a client several ways to create strings that look identical to a human but remain different to software:
- Zero-width characters, such as
U+200B - Variation selectors, including
U+FE00throughU+FE0F - Supplementary variation selectors
- Non-breaking and unusual-width spaces
- Canonically equivalent Unicode sequences
- Text choices selected from equivalent wording variants
For a simplified example, these two Python strings do not compare equal:
plain = "Run the tests"
marked = "Run\u200b the tests"
print(plain)
print(marked)
print(plain == marked)
print(len(plain), len(marked))
Both lines usually render as:
Run the tests
But the comparison returns False, and the marked version contains an additional code point.
A richer marker can encode multiple bits. Suppose a client chooses between a normal space and a zero-width-space-plus-space sequence at eight predetermined positions. Those choices can represent one byte without visibly changing the prompt.
Claude Code requests have demonstrated this broader property: content associated with the client can carry machine-detectable distinctions that are not obvious in rendered prompt text. The exact encoding should be treated as implementation detail. It can change between releases, and the presence of a marker does not by itself establish its purpose. Plausible uses include client recognition, abuse analysis, routing validation, telemetry correlation, and identifying prompts that have passed through an unofficial intermediary.
The observable behavior is more defensible than speculation about intent.
How to Inspect the Request You Actually Send
In practice, inspecting the terminal output is insufficient. You need the serialized request body before TLS encryption, ideally at an API gateway or inside the SDK transport.
Log Escaped Strings, Not Rendered Strings
This Python function reports non-ASCII and format characters without silently hiding them:
import json
import unicodedata
from typing import Any
def inspect_string(value: str, path: str) -> None:
suspicious = []
for index, char in enumerate(value):
category = unicodedata.category(char)
if ord(char) > 127 or category in {"Cf", "Mn", "Me"}:
suspicious.append({
"index": index,
"char": ascii(char),
"codepoint": f"U+{ord(char):04X}",
"name": unicodedata.name(char, "UNKNOWN"),
"category": category,
})
if suspicious:
print(json.dumps({
"path": path,
"python_repr": repr(value),
"utf8_hex": value.encode("utf-8").hex(),
"characters": suspicious,
}, indent=2))
def walk(value: Any, path: str = "$") -> None:
if isinstance(value, str):
inspect_string(value, path)
elif isinstance(value, list):
for index, item in enumerate(value):
walk(item, f"{path}[{index}]")
elif isinstance(value, dict):
for key, item in value.items():
walk(item, f"{path}.{key}")
with open("request.json", "r", encoding="utf-8") as request_file:
walk(json.load(request_file))
Run it against a captured payload:
python inspect_prompt.py
For Run\u200b the tests, the useful part of the output is:
{
"index": 3,
"char": "'\\u200b'",
"codepoint": "U+200B",
"name": "ZERO WIDTH SPACE",
"category": "Cf"
}
Do not flag every non-ASCII character as malicious. Accented names, CJK text, mathematical notation, and emoji are legitimate prompt content. The scanner is a diagnostic tool, not a production policy.
Compare Bytes Across Clients
To establish whether a marker is client-generated, send equivalent prompts through:
- Claude Code
- A minimal direct API script
- Your normal gateway
- Another model provider’s compatible endpoint
Store the exact UTF-8 request body at each controlled boundary. Then compare hashes and escaped content:
sha256sum claude-code-body.json direct-body.json
xxd -g 1 claude-code-body.json | less
diff -u \
<(jq -S . claude-code-body.json) \
<(jq -S . direct-body.json)
A common gotcha is JSON escaping. One logger may store an actual U+200B, while another stores the six visible characters \u200b. Parse the JSON and compare decoded strings, not only files.
Also exclude unrelated differences first:
- Tool ordering
- System prompt versions
- Cache-control blocks
- Request IDs
- Timestamps
- Model aliases
- Automatically collected environment context
Otherwise, ordinary client behavior can be mistaken for hidden marking.
Why This Matters for API Gateways
Many teams put Claude, GPT, and Gemini behind one OpenAI-compatible interface. The gateway translates message schemas, tool definitions, streaming events, and authentication. Prompt-level markers complicate that architecture because they survive some transformations but not others.
| Gateway operation | Marker likely preserved? | Main risk |
|---|---|---|
| Forward raw UTF-8 strings | Yes | Hidden data crosses trust boundaries |
| Parse and re-serialize JSON | Usually | Serializer behavior may differ |
| Apply Unicode NFC normalization | Sometimes | Marker may survive or be altered |
| Strip all format characters | No, for many encodings | Legitimate text can be damaged |
| Summarize or rewrite prompts | Unlikely | Meaning and tool instructions may change |
| Convert messages to another provider schema | Depends | Attribution becomes ambiguous |
What actually happens when a proxy forwards marked content to another provider is easy to overlook: the receiving model may see Claude-specific system instructions and hidden distinctions even though the outbound request is labeled as GPT-5.5 or Gemini 3.
This creates three practical issues.
Attribution Becomes Unreliable
A provider or internal detector may classify the request as originating from Claude Code even after it has traversed your gateway. That classification could be correct at the prompt level but misleading at the transport level.
Keep explicit provenance separately:
{
"request_id": "req_01J...",
"source_client": "claude-code",
"inbound_provider": "anthropic-compatible",
"outbound_provider": "gemini",
"transformations": [
"tool_schema_conversion",
"unicode_nfc"
]
}
Do not put sensitive provenance into the model-visible prompt. Store it in gateway logs or trusted metadata fields.
Cache Keys Can Fragment
Two visually identical prompts with different byte sequences produce different hashes:
import hashlib
def prompt_key(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
assert prompt_key("Run the tests") != prompt_key("Run\u200b the tests")
If your semantic or exact-match cache uses raw bytes, hidden markers lower the hit rate. If you normalize too aggressively, distinct user input can collapse into one key.
A reasonable design uses two hashes:
raw_hashfor audit integritynormalized_hashfor controlled deduplication
Normalization must be versioned. Changing it invalidates cache assumptions.
Token Billing May Change
Invisible does not mean free. Tokenizers can assign tokens to hidden code points or group them with nearby text. The count is model-specific, so measure it using the provider’s tokenizer or returned usage rather than assuming one character equals one token.
Consider 100,000 daily requests with an average hidden overhead of 12 input tokens:
100,000 × 12 = 1,200,000 extra input tokens/day
At an illustrative input rate of $3.00 per million tokens:
1.2 × $3.00 = $3.60/day
$3.60 × 30 = $108/month
That is not automatically a serious cost, but it belongs in capacity and cache calculations. Substitute your actual model rate. AI Prime Tech can reduce Claude, GPT, and Gemini API costs through multi-model access, but lower unit pricing does not remove the need to understand what your client sends.
Should You Strip the Marker?
Usually, not by default.
Blindly deleting every Unicode format or combining character can corrupt multilingual text, source code, emoji presentation, bidirectional text, and identifiers. It can also change signed content or invalidate a provider’s expected client behavior.
If your security model requires sanitization, constrain it to machine-generated prompt sections that you own:
KNOWN_ZERO_WIDTH = {
"\u200b", # zero width space
"\u200c", # zero width non-joiner
"\u200d", # zero width joiner
"\ufeff", # zero width no-break space / BOM
}
def sanitize_generated_text(text: str) -> str:
return "".join(ch for ch in text if ch not in KNOWN_ZERO_WIDTH)
Even this allowlist has trade-offs. U+200D, for example, participates in valid emoji sequences. Apply sanitization to a known ASCII-only generated field, not arbitrary user messages.
For cross-provider routing, I use this policy in practice:
- Preserve the raw inbound payload for audit.
- Parse it once using a strict JSON implementation.
- Record suspicious code points and their field paths.
- Sanitize only fields generated by the client or gateway.
- Never mutate user content silently.
- Version every normalization rule.
- Test each provider independently after transformation.
Building a Regression Test
Add a fixture containing visible, marked, and legitimate Unicode content:
def test_prompt_normalization():
cases = {
"plain": "Run the tests",
"marked": "Run\u200b the tests",
"accented": "Review José's change",
"emoji": "Check status: \u2705",
}
assert sanitize_generated_text(cases["plain"]) == cases["plain"]
assert sanitize_generated_text(cases["marked"]) == "Run the tests"
assert sanitize_generated_text(cases["accented"]) == cases["accented"]
assert sanitize_generated_text(cases["emoji"]) == cases["emoji"]
Then replay representative Claude, GPT, and Gemini requests through the gateway. Verify:
- Tool calls still deserialize.
- Streaming events remain valid.
- Usage accounting is retained.
- Raw and normalized hashes are logged.
- No user-authored Unicode is changed.
- Cache behavior matches the documented policy.
Practical Takeaways
Claude Code’s request marking is a reminder that rendered prompts are not the protocol. Bytes, code points, message structure, and client-generated context all affect what reaches a model.
- Capture request bodies at a trusted pre-encryption boundary.
- Inspect decoded Unicode with code points, names, and UTF-8 hex.
- Separate confirmed byte-level behavior from assumptions about intent.
- Track client provenance explicitly instead of inferring it from prompt text.
- Expect hidden distinctions to affect caching, token counts, and routing.
- Do not strip Unicode globally.
- Sanitize only controlled fields under a documented, versioned policy.
- Preserve the raw payload for debugging and audit.
- Regression-test transformations against every Claude, GPT, and Gemini endpoint you support.
The operational rule is simple: if your gateway cannot show the exact string the model received, including invisible characters, it is not yet giving you a complete request trace.
One API key for Claude Opus 4.8, Sonnet 4.6, Haiku 4.5, Fable 5, plus GPT & Gemini — up to 80% off official pricing, pay-as-you-go.
Get Your API Key →