The Mysterious 400 in Claude Multi-Turn Chats: Debugging a Thinking Signature Corruption

A customer's Claude extended-thinking conversations kept failing with 400 Invalid signature — but only on some gateway channels. A full postmortem: what the signature mechanism is, how a proxy's JSON re-serialization silently corrupts base64, five controlled experiments that pinned it down, and a fix checklist for every LLM gateway developer.

zhuermu · · 10 min
ClaudeExtended ThinkingBedrockLLM GatewayDebuggingBase64

Some channels fail consistently, others work fine · Five controlled experiments · A one-line URL decode with expensive consequences

The Symptom#

A customer using Claude extended thinking in multi-turn conversations kept hitting this on the second (or any later) turn:

400 ValidationException: messages.N.content.0:
Invalid `signature` in `thinking` block

The strange part: same code, same request — only some gateway channels failed, while others worked perfectly.

⚠️ When a failure is consistent on some channels and absent on others, the problem is almost never in the client code. It lives in whatever differs between those infrastructure paths. That observation drove the whole investigation.

Background: What the Signature Is#

Every extended-thinking response from Claude carries a thinking block:

{
  "type": "thinking",
  "thinking": "Let me analyze this...",
  "signature": "EpECCkgIDhABGAIqQNlq...+NcSh70b13lvu...+/KthEQ+NjQGgRWXJeDXu8NX34mcZGAE="
}

Four things to understand:

  • The signature is a cryptographic signature Anthropic’s servers compute over the full thinking content
  • In multi-turn conversations, the client must send the previous turn’s thinking block back unmodified, signature included
  • The API verifies the signature to confirm the thinking block hasn’t been tampered with
  • The signature must survive byte-for-byte — any modification produces a 400

🔍 A counterintuitive detail: the API does not validate the thinking text field at all — the text is just a human-readable transcript, and you can edit it freely. Only the signature matters. One of the experiments below proves this.

The Hypothesis: Proxy JSON Re-Serialization Corrupts the Base64#

Look at the shape of the signature: 300+ characters of standard base64, full of +, /, and = — three characters that carry special meaning in URL encoding and base64url.

If the proxy parses and re-serializes the upstream (Bedrock / Anthropic) response body, any character handling along the way destroys the signature:

Corruption variantCauseEffect
+ → spaceMisapplied URL query-string decodeEpEC...+NcShEpEC... NcSh
+%2BMisapplied URL encodeEpEC...+NcShEpEC...%2BNcSh
Trailing = strippedAn “optimization” strips padding...ZGAE=...ZGAE
+//-/_Misapplied base64url conversionStandard base64 becomes URL-safe base64

The root-cause code typically looks like this (Go):

// ❌ Bug: legacy logic URL-decodes the response body
func forwardResponse(body []byte) []byte {
    decoded, _ := url.QueryUnescape(string(body))
    return []byte(decoded)
}
// Effect: every "+" in the signature becomes a space

Or hides in SSE streaming assembly:

// ❌ Bug: chunk assembly incorrectly trims base64 padding
func assembleStreamingResponse(chunks []string) string {
    var result strings.Builder
    for _, chunk := range chunks {
        result.WriteString(strings.TrimRight(chunk, "="))
    }
    return result.String()
}

Why Only Some Channels Failed#

This also explains the original mystery:

  • Healthy channels: proxy instances pass raw bytes through without JSON re-serialization
  • Failing channels: proxy instances take a code path that parses and re-serializes JSON — for logging, field injection, or response rewriting

One gateway, two code paths, opposite outcomes.

Reproducing It#

With a hypothesis in hand, controlled experiments nail it down. Environment:

  • AWS Bedrock, us-east-1
  • Model: us.anthropic.claude-sonnet-4-5-20250929-v1:0
  • Extended thinking enabled

Obtain a valid signature#

aws bedrock-runtime converse \
  --model-id us.anthropic.claude-sonnet-4-5-20250929-v1:0 \
  --region us-east-1 \
  --additional-model-request-fields '{"thinking":{"budget_tokens":1024,"type":"enabled"}}' \
  --inference-config '{"maxTokens":2048}' \
  --messages '[{"content":[{"text":"What is 2+2? Answer in one word."}],"role":"user"}]'

The response contains a 300+ character base64 signature.

Experiment 1: Replace + with spaces#

Swap every + in the signature for a space, then send turn two:

# Original:  ...RZk+NcSh70b13lvukEj9P3kSDPr7olnJ+WKulQrR+RoM...
# Corrupted: ...RZk NcSh70b13lvukEj9P3kSDPr7olnJ WKulQrR RoM...

aws bedrock-runtime converse \
  --model-id us.anthropic.claude-sonnet-4-5-20250929-v1:0 \
  --region us-east-1 \
  --additional-model-request-fields '{"thinking":{"budget_tokens":1024,"type":"enabled"}}' \
  --inference-config '{"maxTokens":2048}' \
  --messages '[
    {"content":[{"text":"What is 2+2? Answer in one word."}],"role":"user"},
    {"content":[{"reasoningContent":{"reasoningText":{
      "signature":"<corrupted version with + replaced by spaces>",
      "text":"The question asks what 2+2 equals..."
    }}},{"text":"Four"}],"role":"assistant"},
    {"content":[{"text":"Now what is 3+3?"}],"role":"user"}
  ]'

Result: 400 Invalid signature in thinking block

Experiment 2: Truncate the signature#

Drop the trailing AE=:

# Original:  ...mcZGAE=
# Truncated: ...mcZG

Result: 400 Invalid signature in thinking block

Experiment 3: Control group — send it back untouched#

Not a single byte modified.

Result: 200 OK

Experiment 4: Modify the thinking text, keep the signature intact#

Replace the text with something entirely unrelated:

"signature": "<original, unmodified>",
"text": "MODIFIED TEXT - this is completely different"

Result: 200 OK ✅ — confirming the API validates only the signature, never the text.

Results Summary#

Test scenarioSignature stateResult
Sent back untouchedIntact✅ 200
Thinking text modifiedIntact✅ 200
+ replaced with spacesCorrupted❌ 400
Trailing = strippedCorrupted❌ 400
Signature truncatedCorrupted❌ 400
Replayed across us./global. prefixesIntact✅ 200
Replayed across model versions (Opus→Sonnet)Intact✅ 200

📊 The last two rows matter: the signature is not bound to the inference profile prefix or the exact model version — byte integrity is all that counts. This eliminated the competing “cross-region / cross-model causes the 400” hypothesis.

The Fix#

🎯 Core principle: the proxy layer must pass the thinking block’s signature field through byte-for-byte, with zero processing.

Audit Checklist#

A self-check list for every LLM gateway developer:

  1. Does the proxy run url.QueryUnescape() or anything similar on the response body?
  2. Does the JSON serialization library treat + / = / / inside strings specially?
  3. Can the streaming (SSE) assembly logic truncate content?
  4. Does any middleware convert base64 → base64url?
  5. Do logging/audit modules introduce character transformations when they re-serialize?

The Fix Itself#

The best fix is to not parse at all — pass bytes through:

// ✅ Correct: raw passthrough of the upstream response body
func forwardUpstreamResponse(w http.ResponseWriter, upstreamResp *http.Response) {
    w.Header().Set("Content-Type", upstreamResp.Header.Get("Content-Type"))
    io.Copy(w, upstreamResp.Body)
}

If you genuinely must parse the JSON (say, to inject fields), protect sensitive fields with json.RawMessage:

// ✅ Correct: RawMessage preserves the original bytes
type ThinkingBlock struct {
    Type      string          `json:"type"`
    Thinking  string          `json:"thinking"`
    Signature json.RawMessage `json:"signature"` // never transformed
}

And add a tripwire — hash the signature before and after forwarding:

incomingSig := extractSignature(upstreamResponse)
outgoingSig := extractSignature(forwardedResponse)
if md5(incomingSig) != md5(outgoingSig) {
    log.Error("SIGNATURE CORRUPTED in proxy layer!")
}

Appendix: Why Anthropic Designed the Signature Mechanism#

PurposeExplanation
Tamper resistancePrevents a man-in-the-middle from editing thinking content to steer Claude’s subsequent reasoning
Reasoning continuityIn tool-use scenarios, Claude resumes reasoning from the previous turn’s thinking checkpoint
Safety guardrailPrevents forged thinking blocks from bypassing safety constraints
Cross-platform compatibilityThe signature works across the Anthropic API, Bedrock, and Vertex AI

Closing Thoughts#

The lesson from this case compresses into one sentence: every byte your gateway touches is your responsibility.

LLM API responses increasingly carry cryptographic signatures and binary encodings — fields where not a single byte may change. If your gateway re-serializes JSON, transforms characters, or rewrites content anywhere on the forwarding path, run it through the checklist above. Today it’s the thinking signature; tomorrow it might be a tool call ID. Raw passthrough is always the safest default.

References

  1. Building with extended thinking — Anthropic Documentation
  2. Amazon Bedrock Converse API — AWS Documentation

Frequently asked

What is the signature inside a Claude thinking block?
The signature is a cryptographic signature that Anthropic's servers generate over the full thinking content. When you send thinking blocks back in a multi-turn conversation, the API validates only the signature field byte-for-byte — it does not validate the thinking text itself. Change a single byte of the signature and you get a 400.
Why would my LLM gateway corrupt the signature?
The most common cause is a proxy that parses and re-serializes the response JSON with a character transformation mixed in: a URL decode (+ becomes a space), a base64url conversion (+/ become -_), or padding stripping (trailing = removed). The signature is standard base64, so any of these breaks it.
How do I quickly confirm the proxy is corrupting the signature?
Run the same multi-turn request directly against the upstream (Bedrock or the Anthropic API), bypassing the proxy. If direct calls return 200 while proxied calls return 400, you have your answer. To go further, hash the signature before and after forwarding inside the proxy — a mismatch proves the corruption happens in the gateway.
Share this article X LinkedIn Hacker News Reddit

Read next

Get new posts by email

One email when a new article goes up. No ads, unsubscribe in one click.

Your address is used for post notifications only.

Discussion

Comments are GitHub Discussions on this repository; sign in with GitHub to post.