# 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.

- Author: zhuermu
- Published: 2026-07-18
- Web version: https://zhuermu.com/en/blog/claude-thinking-signature-corruption/

---
**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:

```json
{
  "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 variant | Cause | Effect |
|---------|------|------|
| `+` → space | Misapplied URL query-string decode | `EpEC...+NcSh` → `EpEC... NcSh` |
| `+` → `%2B` | Misapplied URL encode | `EpEC...+NcSh` → `EpEC...%2BNcSh` |
| Trailing `=` stripped | An "optimization" strips padding | `...ZGAE=` → `...ZGAE` |
| `+`/`/` → `-`/`_` | Misapplied base64url conversion | Standard base64 becomes URL-safe base64 |

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

```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:

```go
// ❌ 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

```bash
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:

```bash
# 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=`:

```bash
# 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:

```bash
"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 scenario | Signature state | Result |
|---------|---------------|------|
| Sent back untouched | Intact | ✅ 200 |
| Thinking text modified | Intact | ✅ 200 |
| `+` replaced with spaces | Corrupted | ❌ 400 |
| Trailing `=` stripped | Corrupted | ❌ 400 |
| Signature truncated | Corrupted | ❌ 400 |
| Replayed across us./global. prefixes | Intact | ✅ 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:

```go
// ✅ 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`:

```go
// ✅ 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:

```go
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

| Purpose | Explanation |
|------|------|
| Tamper resistance | Prevents a man-in-the-middle from editing thinking content to steer Claude's subsequent reasoning |
| Reasoning continuity | In tool-use scenarios, Claude resumes reasoning from the previous turn's thinking checkpoint |
| Safety guardrail | Prevents forged thinking blocks from bypassing safety constraints |
| Cross-platform compatibility | The 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.

---

## 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.


---

## References

- [Building with extended thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) — Anthropic Documentation
- [Amazon Bedrock Converse API](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html) — AWS Documentation
