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
The Mysterious 400 in Claude Multi-Turn Chats: Debugging a Thinking Signature Corruption

中文版 / Chinese Version: Claude 多轮对话神秘 400:一次 Thinking Signature 损坏的排查实录

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