Repository: lestrrat-go/jwx

Issue:
Duplicate header params across protected & per-recipient headers (violates RFC 7516 §7.2.1)
 **Summary**
When creating a JWE with JSON Serialization, the same header parameters (e.g., `kid`, `alg`) appear in both the **protected** and **per-recipient unprotected** (`header`) sections.
RFC 7516 requires that the header parameter **names across the three locations—`protected`, `unprotected`, and per-recipient `header`—“MUST be disjoint.”**

**RFC reference:** [https://www.rfc-editor.org/rfc/rfc7516#section-7.2.1](https://www.[rfc-editor.org](https://www.rfc-editor.org/rfc/rfc7516)/rfc/rfc7516#section-7.2.1)

---
```go
package jwxmergeissue

import (
	"crypto/rand"
	"crypto/rsa"
	"encoding/base64"
	"encoding/json"
	"testing"

	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwe"
	"github.com/stretchr/testify/require"
)

func TestDuplicateHeaders(t *testing.T) {
	recipientPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)

	// Per-recipient unprotected header includes kid
	recipientHeaders := jwe.NewHeaders()
	require.NoError(t, recipientHeaders.Set("kid", "recipient1"))

	recipient := jwe.WithKey(
		jwa.RSA_OAEP_256(),
		recipientPrivateKey.PublicKey,
		jwe.WithPerRecipientHeaders(recipientHeaders),
	)

	// Produce JSON Serialization (flattened for single recipient)
	ret, err := jwe.Encrypt([]byte("Hello!"), jwe.WithJSON(), recipient)
	require.NoError(t, err)

	var jweJSON map[string]any
	require.NoError(t, json.Unmarshal(ret, &jweJSON))

	// Decode protected header (Base64URL)
	protectedB64, ok := jweJSON["protected"].(string)
	require.True(t, ok)

	protectedJSON, err := base64.RawURLEncoding.DecodeString(protectedB64)
	require.NoError(t, err)

	var protected map[string]any
	require.NoError(t, json.Unmarshal(protectedJSON, &protected))

	// Per-recipient header
	recipHeader, ok := jweJSON["header"].(map[string]any)
	require.True(t, ok)

	// Fail if the same names appear in both places (violates RFC 7516 §7.2.1)
	if _, has := protected["kid"]; has {
		if _, also := recipHeader["kid"]; also {
			t.Fatalf("duplicate 'kid' present in both protected and per-recipient headers")
		}
	}
	if _, has := protected["alg"]; has {
		if _, also := recipHeader["alg"]; also {
			t.Fatalf("duplicate 'alg' present in both protected and per-recipient headers")
		}
	}
}
```

**Expected behavior**
Library should prevent duplicate header **names** across `protected`, `unprotected`, and `per-recipient` headers

go.mod file:
```go
module github.com/ilya-korotya/jwx_merge_issue

go 1.24.4

toolchain go1.24.8

require (
	github.com/lestrrat-go/jwx/v3 v3.0.11
	github.com/stretchr/testify v1.11.1
)

require (
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
	github.com/goccy/go-json v0.10.3 // indirect
	github.com/lestrrat-go/blackmagic v1.0.4 // indirect
	github.com/lestrrat-go/httpcc v1.0.1 // indirect
	github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect
	github.com/lestrrat-go/option v1.0.1 // indirect
	github.com/lestrrat-go/option/v2 v2.0.0 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/segmentio/asm v1.2.0 // indirect
	golang.org/x/crypto v0.42.0 // indirect
	golang.org/x/sys v0.36.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)
```

Hints from issue comments:
@ilya-korotya: This [merge seems to be the cause](https://github.com/lestrrat-go/jwx/blob/1a4f2aed2be67015f06aec32b2160608be485b8e/jwe/jwe.go#L770-L778):
```go
    // If there's only one recipient, you want to include that in the
	// protected header
	if len(recipients) == 1 {
		h, err := protected.Merge(recipients[0].Headers())
		if err != nil {
			return nil, fmt.Errorf(`failed to merge protected headers: %w`, err)
		}
		protected = h
	}
```
After merging, the library should remove the headers from the recipient to avoid duplication.

@lestrrat: Thanks. I remember that piece of code, and I think it's ancient -- it probably was there from pre-v1 days in some form, and I'm pretty sure I copied that from somewhere ... :)

---

(below is me basically thinking out loud so that I can remember it later; definitely not my final conclusion on this matter yet)

So,  merge and destroy the old headers? is it safe...? -> it should be safe, as when there is only one recipient, there is only one header.

```go
if len(recipients) == 1 {
   ...
   recipents[0].SetHeaders( nil ) // I forget if I could assign nil, but this is the intention
}
```

Although it being the correct thing to do, I think removing the headers would have to be optional to keep backwards compatibility.

```go
// globally
jwe.Settings(jwe.WithDisjointHeaders(true))
jwe.Encrypt(....) // always new behavior

// per-call
jwe.Encrpt(...) // old behavior
jwe.Encrypt(jwe.WithDisjointHeaders(true), ...) // new behavior
```@lestrrat: @ilya-korotya In your test code, Do you expect kid/alg to be in the public header portion? I think they should only be available in the protected headers?@ilya-korotya: Hi @lestrrat. I'm not fully sure which headers should be moved to the protected section and which should stay in the participant header section.
Here is how I understand it (only in case if `jwe.WithJSON()` option was passed and only one recipient is presented):
1. Integrity-protected headers — like alg, enc, tag, etc. — should be moved by the library from the participant header section to the protected header section.
2. Non-integrity-protected headers — like kid, typ, cty, etc. — can stay in the participant header section, **but we must make sure they are not duplicated in the protected header section.**

I think we can simplify the logic. The library can copy all headers from the `per-participant` header section to the `protected` header section, and then clear the `per-participant` headers. This should happen only when the JSON serialization option is used and there is only one recipient.

Our proposal with `jwe.WithDisjointHeaders(true)` option LGTM.@lestrrat: > This should happen only when the JSON serialization option is used and there is only one recipient.

Nah, it needs to happen for compact format too, as there are no per-recipient headers.
Also, there's this thing called the flattened JSON format... (rolls eyes)

I need to take sometime to think this over to make sure I can actually fix this without breaking stuff@lestrrat: I'm just thinking out loud, and leaving a comment so I can remember what I was thinking:

* I think the current way we pack things into protected header works, but is incorrect
* Ideally, the serialization should be exactly the same as the data specified the user, but this only applies for full JSON format
   * This has security implications, because the user _could_ include fields that should be integrity-protected in an open header. 
* For flattened JSON and compact format, packing things into protected headers is necessary

I probably wrote that code to pack things into the protected header when this library did not have any support for full JSON format, and that's why everything looked peachy -- the encryption works as intended. It's just that when using the full-JSON format, unpacked representations come out in a format that's unexpected.

Now, how to fix this...

We're probably going to need a "keep the old behavior mode" and "do the right thing mode".
The implementation is probably pretty straightforward. It would mostly come down to naming the option, and documentation so users know what they should do.

For starters, we need to preserve the old behavior, because we don't want to break people's existing code. So to get the new (correct) behavior, we need to add an option to `jwe.Encrypt`:

```go
// old behavior
jwe.Encrypt(....)

// new behavior
// nevermind the option name. it's a placeholder for now
jwe.Encrypt(...., jwe.PreserveOriginalHeaders())
```

We would need to add ample documentation in both `jwe.Encrypt` and the new option.@lestrrat: ~~More memo.~~

~~Upon further debugging, though this library needs to be fixed I think the user's expectations are also wrong.~~

~~This library should NEVER merge per-recipient headers to the protected headers -- we need to use the protected headers as-is.
The user cannot specify fields such as "kid" (which should be in the protected header) in the per-recipient header.~~

~~So this library also needs to flat out reject per-recipient headers when using compact serialization / flattened JSON serialization.~~

~~We should allow the user to request opt-in no-merging behavior.~~

~~For v4, no-merging  should be the default.~~@lestrrat: @ilya-korotya please take a look at #1477, see if it does what you wanted. You do need to opt-in by specifying a  new option.@ilya-korotya: Hi @lestrrat. The PR LGTM. Thanks!

I found one more issue. Now the same unit test works and returns a token in this format:
```json
{
  "ciphertext": "NR5BBCPc",
  "encrypted_key": "PENNLqJYzXYbKgiZCTe61Qhasls8wrqGQ7ytsvvm5_E6Yya79QyrtDaX_lYm2FitLPMkw4K-63NJf1Ltp9Ur-zZUJB2rQmGkhB4toBO-SkLSkSPhUr6lILKIFWrnbBjjy63g_Awfqb7tsWT1kD0rqO_xPhhN9s0xoHMpgFYY80QQxUBVYiADiu01H_o9tsh66Npch3HAI6t6bxUfVwyMjpWK7N6k74vc95l3-ZcAGxl6aeWAtUTD37bE5bVbvILfcb6HdK0Z81fmPcRe2o9W4wZ_SP2fEV67I-jrsx73rgL1a1c_9TB4vZX3XxppvBhwKHUBAksaq3aWQXr8gkWt-g",
  "header": {},
  "iv": "TPrbYWe0oUeC9Eop",
  "protected": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoicmVjaXBpZW50MSJ9",
  "tag": "tOk33fmFy80qZVNyb22s0g"
}
```
By the RFC, if the recipient `header` is empty, it should be **omitted**. If I remove the `header` field and try to parse the flattened JWE, I get an error. In this case, all the headers needed to parse/decrypt the token are in the protected headers. I think the library should combine all possible headers before parse/decrypting and if the headings overlap, return an error.

```bash
echo eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoicmVjaXBpZW50MSJ9 | base64 -d |
jq .
{
  "alg": "RSA-OAEP-256",
  "enc": "A256GCM",
  "kid": "recipient1"
}
```

```go
func TestParseWithoutHeaderForFlattedToken(t *testing.T) {
	token := `{
    "ciphertext": "NR5BBCPc",
    "encrypted_key": "PENNLqJYzXYbKgiZCTe61Qhasls8wrqGQ7ytsvvm5_E6Yya79QyrtDaX_lYm2FitLPMkw4K-63NJf1Ltp9Ur-zZUJB2rQmGkhB4toBO-SkLSkSPhUr6lILKIFWrnbBjjy63g_Awfqb7tsWT1kD0rqO_xPhhN9s0xoHMpgFYY80QQxUBVYiADiu01H_o9tsh66Npch3HAI6t6bxUfVwyMjpWK7N6k74vc95l3-ZcAGxl6aeWAtUTD37bE5bVbvILfcb6HdK0Z81fmPcRe2o9W4wZ_SP2fEV67I-jrsx73rgL1a1c_9TB4vZX3XxppvBhwKHUBAksaq3aWQXr8gkWt-g",
    "iv": "TPrbYWe0oUeC9Eop",
    "protected": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoicmVjaXBpZW50MSJ9",
    "tag": "tOk33fmFy80qZVNyb22s0g"
}`

	_, err := jwe.Parse([]byte(token))
	require.NoError(t, err)
}
```
Output:
```txt
=== RUN   TestParseWithoutHeaderForFlattedToken
    ./src/github.com/ilya-korotya/jwx_merge_issue/merge_test.go:76:
        	Error Trace:	./jwx_merge_issue/merge_test.go:76
        	Error:      	Received unexpected error:
        	            	jwe.Parse: failed to parse JSON: failed to decode headers field: EOF
        	Test:       	TestParseWithoutHeaderForFlattedToken
--- FAIL: TestParseWithoutHeaderForFlattedToken (0.00s)
FAIL
FAIL	github.com/ilya-korotya/jwx_merge_issue	0.209s
```
It looks like `jwe.Parse` fails when `header` is missing in a flattened token, even though an empty `header` should be equivalent to omitting it. Could you confirm if this is a bug? If yes, we may need to treat a missing `header` the same as `{}`.
@lestrrat: Ah, nice catch! I made some fixes in https://github.com/lestrrat-go/jwx/pull/1477/commits/4eabe7787cb4c7d3dd88aea98bda6c994fc9b56c
Please check the PR again?@lestrrat: Thanks, merged. I'm not going to make immediate releases; history (i.e. my own dumb actions) has taught me that rushing a release is never a good idea, and so it will be resting in the repo for at least a few days if not more. If you want this feature now, please pin the git commit ID for the time being.

Solution patch:
diff --git a/.golangci.yml b/.golangci.yml
index 4ea45fdd..30dc4c51 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -15,7 +15,6 @@ linters:
     - gocognit
     - gocritic
     - gocyclo
-    - godoclint
     - godot
     - godox
     - gosec
diff --git a/Changes b/Changes
index edea48f0..1e31f663 100644
--- a/Changes
+++ b/Changes
@@ -5,6 +5,30 @@ v3 has many incompatibilities with v2. To see the full list of differences betwe
 v2 and v3, please read the Changes-v3.md file (https://github.com/lestrrat-go/jwx/blob/develop/v3/Changes-v3.md)
 
 v3.0.12 UNRELEASED
+  * [jwe] As part of the next change, now per-recipient headers that are empty
+    are no longer serialized in flattened JSON serialization.
+
+  * [jwe] Introduce `jwe.WithLegacyHeaderMerging(bool)` option to control header
+    merging behavior in during JWE encryption. This only applies to flattened
+    JSON serialization.
+
+    Previously, when using flattened JSON serialization (i.e. you specified
+    JSON serialization via `jwe.WithJSON()` and only supplied one key), per-recipient
+    headers were merged into the protected headers during encryption, and then
+    were left to be included in the final serialization as-is. This caused duplicate
+    headers to be present in both the protected headers and the per-recipient headers.
+
+    Since there maybe users who rely on this behavior already, instead of changing the
+    default behavior to fix this duplication, a new option to `jwe.Encrypt()` was added
+    to allow clearing the per-recipient headers after merging to leave the `"headers"`
+    field empty. This in effect makes the flattened JSON serialization more similar to
+    the compact serialization, where there are no per-recipient headers present, and
+    leaves the headers disjoint.
+    
+    Note that in compact mode, there are no per-recipient headers and thus the
+    headers need to be merged regardless. In full JSON serialization, we never
+    merge the headers, so it is left up to the user to keep the headers disjoint.
+
   * [jws] Calling the deprecated `jws.NewSigner()` function for the time will cause
     legacy signers to be loaded automatically. Previously, you had to explicitly
     call `jws.Settings(jws.WithLegacySigners(true))` to enable legacy signers.
diff --git a/jwe/jwe.go b/jwe/jwe.go
index 5728021e..5b9c9277 100644
--- a/jwe/jwe.go
+++ b/jwe/jwe.go
@@ -99,15 +99,20 @@ func (b *recipientBuilder) Build(r Recipient, cek []byte, calg jwa.ContentEncryp
 		rawKey = raw
 	}
 
-	// Extract ECDH-ES specific parameters if needed
+	// Extract ECDH-ES specific parameters if needed.
 	var apu, apv []byte
-	if b.headers != nil {
-		if val, ok := b.headers.AgreementPartyUInfo(); ok {
-			apu = val
-		}
-		if val, ok := b.headers.AgreementPartyVInfo(); ok {
-			apv = val
-		}
+
+	hdr := b.headers
+	if hdr == nil {
+		hdr = NewHeaders()
+	}
+
+	if val, ok := hdr.AgreementPartyUInfo(); ok {
+		apu = val
+	}
+
+	if val, ok := hdr.AgreementPartyVInfo(); ok {
+		apv = val
 	}
 
 	// Create the encrypter using the new jwebb pattern
@@ -116,20 +121,20 @@ func (b *recipientBuilder) Build(r Recipient, cek []byte, calg jwa.ContentEncryp
 		return nil, fmt.Errorf(`jwe.Encrypt: recipientBuilder: failed to create encrypter: %w`, err)
 	}
 
-	if hdrs := b.headers; hdrs != nil {
-		_ = r.SetHeaders(hdrs)
-	}
+	_ = r.SetHeaders(hdr)
 
-	if err := r.Headers().Set(AlgorithmKey, b.alg); err != nil {
+	// Populate headers with stuff that we automatically set
+	if err := hdr.Set(AlgorithmKey, b.alg); err != nil {
 		return nil, fmt.Errorf(`failed to set header: %w`, err)
 	}
 
 	if keyID != "" {
-		if err := r.Headers().Set(KeyIDKey, keyID); err != nil {
+		if err := hdr.Set(KeyIDKey, keyID); err != nil {
 			return nil, fmt.Errorf(`failed to set header: %w`, err)
 		}
 	}
 
+	// Handle the encrypted key
 	var rawCEK []byte
 	enckey, err := enc.EncryptKey(cek)
 	if err != nil {
@@ -143,8 +148,9 @@ func (b *recipientBuilder) Build(r Recipient, cek []byte, calg jwa.ContentEncryp
 		}
 	}
 
+	// finally, anything specific should go here
 	if hp, ok := enckey.(populater); ok {
-		if err := hp.Populate(r.Headers()); err != nil {
+		if err := hp.Populate(hdr); err != nil {
 			return nil, fmt.Errorf(`failed to populate: %w`, err)
 		}
 	}
@@ -154,7 +160,9 @@ func (b *recipientBuilder) Build(r Recipient, cek []byte, calg jwa.ContentEncryp
 
 // Encrypt generates a JWE message for the given payload and returns
 // it in serialized form, which can be in either compact or
-// JSON format. Default is compact.
+// JSON format. Default is compact. When JSON format is specified and
+// there is only one recipient, the resulting serialization is
+// automatically converted to flattened JSON serialization format.
 //
 // You must pass at least one key to `jwe.Encrypt()` by using `jwe.WithKey()`
 // option.
@@ -172,6 +180,10 @@ func (b *recipientBuilder) Build(r Recipient, cek []byte, calg jwa.ContentEncryp
 //
 // Look for options that return `jwe.EncryptOption` or `jws.EncryptDecryptOption`
 // for a complete list of options that can be passed to this function.
+//
+// As of v3.0.12, users can specify `jwe.WithLegacyHeaderMerging()` to
+// disable header merging behavior that was the default prior to v3.0.12.
+// Read the documentation for `jwe.WithLegacyHeaderMerging()` for more information.
 func Encrypt(payload []byte, options ...EncryptOption) ([]byte, error) {
 	ec := encryptContextPool.Get()
 	defer encryptContextPool.Put(ec)
@@ -410,10 +422,26 @@ func (dc *decryptContext) decryptContent(msg *Message, alg jwa.KeyEncryptionAlgo
 		Tag(msg.tag).
 		CEK(dc.cek)
 
-	if v, ok := recipient.Headers().Algorithm(); !ok || v != alg {
-		// algorithms don't match
+	// The "alg" header can be in either protected/unprotected headers.
+	// prefer per-recipient headers (as it might be the case that the algorithm differs
+	// by each recipient), then look at protected headers.
+	var algMatched bool
+	for _, hdr := range []Headers{recipient.Headers(), protectedHeaders} {
+		v, ok := hdr.Algorithm()
+		if !ok {
+			continue
+		}
+
+		if v == alg {
+			algMatched = true
+			break
+		}
+		// if we found something but didn't match, it's a failure
 		return nil, fmt.Errorf(`jwe.Decrypt: key (%q) and recipient (%q) algorithms do not match`, alg, v)
 	}
+	if !algMatched {
+		return nil, fmt.Errorf(`jwe.Decrypt: failed to find "alg" header in either protected or per-recipient headers`)
+	}
 
 	h2, err := protectedHeaders.Clone()
 	if err != nil {
@@ -534,11 +562,12 @@ func (dc *decryptContext) decryptContent(msg *Message, alg jwa.KeyEncryptionAlgo
 
 // encryptContext holds the state during JWE encryption, similar to JWS signContext
 type encryptContext struct {
-	calg        jwa.ContentEncryptionAlgorithm
-	compression jwa.CompressionAlgorithm
-	format      int
-	builders    []*recipientBuilder
-	protected   Headers
+	calg                jwa.ContentEncryptionAlgorithm
+	compression         jwa.CompressionAlgorithm
+	format              int
+	builders            []*recipientBuilder
+	protected           Headers
+	legacyHeaderMerging bool
 }
 
 var encryptContextPool = pool.New(allocEncryptContext, freeEncryptContext)
@@ -561,6 +590,7 @@ func freeEncryptContext(ec *encryptContext) *encryptContext {
 }
 
 func (ec *encryptContext) ProcessOptions(options []EncryptOption) error {
+	ec.legacyHeaderMerging = true
 	var mergeProtected bool
 	var useRawCEK bool
 	for _, option := range options {
@@ -577,7 +607,11 @@ func (ec *encryptContext) ProcessOptions(options []EncryptOption) error {
 			if v == jwa.DIRECT() || v == jwa.ECDH_ES() {
 				useRawCEK = true
 			}
-			ec.builders = append(ec.builders, &recipientBuilder{alg: v, key: wk.key, headers: wk.headers})
+			ec.builders = append(ec.builders, &recipientBuilder{
+				alg:     v,
+				key:     wk.key,
+				headers: wk.headers,
+			})
 		case identContentEncryptionAlgorithm{}:
 			var c jwa.ContentEncryptionAlgorithm
 			if err := option.Value(&c); err != nil {
@@ -616,6 +650,12 @@ func (ec *encryptContext) ProcessOptions(options []EncryptOption) error {
 				return err
 			}
 			ec.format = fmtOpt
+		case identLegacyHeaderMerging{}:
+			var v bool
+			if err := option.Value(&v); err != nil {
+				return err
+			}
+			ec.legacyHeaderMerging = v
 		}
 	}
 
@@ -732,7 +772,8 @@ func (ec *encryptContext) EncryptMessage(payload []byte, cek []byte) ([]byte, er
 		}
 	}
 
-	recipients := recipientSlicePool.GetCapacity(len(ec.builders))
+	lbuilders := len(ec.builders)
+	recipients := recipientSlicePool.GetCapacity(lbuilders)
 	defer recipientSlicePool.Put(recipients)
 
 	for i, builder := range ec.builders {
@@ -767,14 +808,55 @@ func (ec *encryptContext) EncryptMessage(payload []byte, cek []byte) ([]byte, er
 		}
 	}
 
-	// If there's only one recipient, you want to include that in the
-	// protected header
-	if len(recipients) == 1 {
+	// fmtCompact does not have per-recipient headers, nor a "header" field.
+	// In this mode, we're going to have to merge everything to the protected
+	// header.
+	if ec.format == fmtCompact {
+		// We have already established that the number of builders is 1 in
+		// ec.ProcessOptions(). But we're going to be pedantic
+		if lbuilders != 1 {
+			return nil, fmt.Errorf(`internal error: expected exactly one recipient builder (got %d)`, lbuilders)
+		}
+
+		// when we're using compact format, we can safely merge per-recipient
+		// headers into the protected header, if any
 		h, err := protected.Merge(recipients[0].Headers())
 		if err != nil {
-			return nil, fmt.Errorf(`failed to merge protected headers: %w`, err)
+			return nil, fmt.Errorf(`failed to merge protected headers for compact serialization: %w`, err)
 		}
 		protected = h
+		// per-recipient headers, if any, will be ignored in compact format
+	} else {
+		// If it got here, it's JSON (could be pretty mode, too).
+		if lbuilders == 1 {
+			// If it got here, then we're doing flattened JSON serialization.
+			// In this mode, we should merge per-recipient headers into the protected header,
+			// but we also need to make sure that the "header" field is reset so that
+			// it does not contain the same fields as the protected header.
+			//
+			// However, old behavior was to merge per-recipient headers into the
+			// protected header when there was only one recipient, AND leave the
+			// original "header" field as is, so we need to support that for backwards compatibility.
+			//
+			// The legacy merging only takes effect when there is exactly one recipient.
+			//
+			// This behavior can be disabled by passing jwe.WithLegacyHeaderMerging(false)
+			// If the user has explicitly asked for merging, do it
+			h, err := protected.Merge(recipients[0].Headers())
+			if err != nil {
+				return nil, fmt.Errorf(`failed to merge protected headers for flattenend JSON format: %w`, err)
+			}
+			protected = h
+
+			if !ec.legacyHeaderMerging {
+				// Clear per-recipient headers, since they have been merged.
+				// But we only do it when legacy merging is disabled.
+				// Note: we should probably introduce a Reset() method in v4
+				if err := recipients[0].SetHeaders(NewHeaders()); err != nil {
+					return nil, fmt.Errorf(`failed to clear per-recipient headers after merging: %w`, err)
+				}
+			}
+		}
 	}
 
 	aad, err := protected.Encode()
diff --git a/jwe/message.go b/jwe/message.go
index 13cf3dec..7aad833f 100644
--- a/jwe/message.go
+++ b/jwe/message.go
@@ -265,14 +265,23 @@ func (m *Message) MarshalJSON() ([]byte, error) {
 	if recipients := m.Recipients(); len(recipients) > 0 {
 		if len(recipients) == 1 { // Use flattened format
 			if hdrs := recipients[0].Headers(); hdrs != nil {
-				buf.Reset()
-				if err := enc.Encode(hdrs); err != nil {
-					return nil, fmt.Errorf(`failed to encode %s field: %w`, HeadersKey, err)
+				var skipHeaders bool
+				if zeroer, ok := hdrs.(isZeroer); ok {
+					if zeroer.isZero() {
+						skipHeaders = true
+					}
+				}
+
+				if !skipHeaders {
+					buf.Reset()
+					if err := enc.Encode(hdrs); err != nil {
+						return nil, fmt.Errorf(`failed to encode %s field: %w`, HeadersKey, err)
+					}
+					fields = append(fields, jsonKV{
+						Key:   HeadersKey,
+						Value: strings.TrimSpace(buf.String()),
+					})
 				}
-				fields = append(fields, jsonKV{
-					Key:   HeadersKey,
-					Value: strings.TrimSpace(buf.String()),
-				})
 			}
 
 			if ek := recipients[0].EncryptedKey(); len(ek) > 0 {
@@ -369,13 +378,18 @@ func (m *Message) UnmarshalJSON(buf []byte) error {
 	// field. TODO: do both of these conditions need to meet, or just one?
 	if proxy.Headers != nil || len(proxy.EncryptedKey) > 0 {
 		recipient := NewRecipient()
-		hdrs := NewHeaders()
-		if err := json.Unmarshal(proxy.Headers, hdrs); err != nil {
-			return fmt.Errorf(`failed to decode headers field: %w`, err)
-		}
 
-		if err := recipient.SetHeaders(hdrs); err != nil {
-			return fmt.Errorf(`failed to set new headers: %w`, err)
+		// `"heders"` could be empty. If that's the case, just skip the
+		// following unmarshaling step
+		if proxy.Headers != nil {
+			hdrs := NewHeaders()
+			if err := json.Unmarshal(proxy.Headers, hdrs); err != nil {
+				return fmt.Errorf(`failed to decode headers field: %w`, err)
+			}
+
+			if err := recipient.SetHeaders(hdrs); err != nil {
+				return fmt.Errorf(`failed to set new headers: %w`, err)
+			}
 		}
 
 		if v := proxy.EncryptedKey; len(v) > 0 {
diff --git a/jwe/options.yaml b/jwe/options.yaml
index b7fb0262..359d8094 100644
--- a/jwe/options.yaml
+++ b/jwe/options.yaml
@@ -169,4 +169,42 @@ options:
       If set to an invalid value, the default value is used.
       In v2, this option was called MaxBufferSize.
 
-      This option has a global effect.
\ No newline at end of file
+      This option has a global effect.
+  - ident: LegacyHeaderMerging
+    interface: EncryptOption
+    argument_type: bool
+    option_name: WithLegacyHeaderMerging
+    comment: |
+      WithLegacyHeaderMerging specifies whether to perform legacy header merging
+      when encrypting a JWE message in JSON serialization, when there is a single recipient.
+      This behavior is enabled by default for backwards compatibility.
+
+      When a JWE message is encrypted in JSON serialization, and there is only
+      one recipient, this library automatically serializes the message in
+      flattened JSON serialization format. In older versions of this library,
+      the protected headers and the per-recipient headers were merged together
+      before computing the AAD (Additional Authenticated Data), but the per-recipient
+      headers were kept as-is in the `header` field of the recipient object.
+
+      This behavior is not compliant with the JWE specification, which states that
+      the headers must be disjoint.
+
+      Passing this option with a value of `false` disables this legacy behavior,
+      and while the per-recipient headers and protected headers are still merged
+      for the purpose of computing AAD, the per-recipient headers are cleared
+      after merging, so that the resulting JWE message is compliant with the
+      specification.
+
+      This option has no effect when there are multiple recipients, or when
+      the serialization format is compact serialization. For multiple recipients
+      (i.e. full JSON serialization), the protected headers and per-recipient
+      headers are never merged, and it is the caller's responsibility to ensure
+      that the headers are disjoint. In compact serialization, there are no per-recipient
+      headers; in fact, the protected headers are the only headers that exist,
+      and therefore there is no possibility of header collision after merging
+      (note: while per-recipient headers do not make sense in compact serialization,
+      this library does not prevent you from setting them -- they are all just
+      merged into the protected headers).
+
+      In future versions, the new behavior will be the default. New users are
+      encouraged to set this option to `false` now to avoid future issues.
\ No newline at end of file
diff --git a/jwe/options_gen.go b/jwe/options_gen.go
index 2a15c141..2d28eecb 100644
--- a/jwe/options_gen.go
+++ b/jwe/options_gen.go
@@ -147,6 +147,7 @@ type identFS struct{}
 type identKey struct{}
 type identKeyProvider struct{}
 type identKeyUsed struct{}
+type identLegacyHeaderMerging struct{}
 type identMaxDecompressBufferSize struct{}
 type identMaxPBES2Count struct{}
 type identMergeProtectedHeaders struct{}
@@ -193,6 +194,10 @@ func (identKeyUsed) String() string {
 	return "WithKeyUsed"
 }
 
+func (identLegacyHeaderMerging) String() string {
+	return "WithLegacyHeaderMerging"
+}
+
 func (identMaxDecompressBufferSize) String() string {
 	return "WithMaxDecompressBufferSize"
 }
@@ -292,6 +297,43 @@ func WithKeyUsed(v any) DecryptOption {
 	return &decryptOption{option.New(identKeyUsed{}, v)}
 }
 
+// WithLegacyHeaderMerging specifies whether to perform legacy header merging
+// when encrypting a JWE message in JSON serialization, when there is a single recipient.
+// This behavior is enabled by default for backwards compatibility.
+//
+// When a JWE message is encrypted in JSON serialization, and there is only
+// one recipient, this library automatically serializes the message in
+// flattened JSON serialization format. In older versions of this library,
+// the protected headers and the per-recipient headers were merged together
+// before computing the AAD (Additional Authenticated Data), but the per-recipient
+// headers were kept as-is in the `header` field of the recipient object.
+//
+// This behavior is not compliant with the JWE specification, which states that
+// the headers must be disjoint.
+//
+// Passing this option with a value of `false` disables this legacy behavior,
+// and while the per-recipient headers and protected headers are still merged
+// for the purpose of computing AAD, the per-recipient headers are cleared
+// after merging, so that the resulting JWE message is compliant with the
+// specification.
+//
+// This option has no effect when there are multiple recipients, or when
+// the serialization format is compact serialization. For multiple recipients
+// (i.e. full JSON serialization), the protected headers and per-recipient
+// headers are never merged, and it is the caller's responsibility to ensure
+// that the headers are disjoint. In compact serialization, there are no per-recipient
+// headers; in fact, the protected headers are the only headers that exist,
+// and therefore there is no possibility of header collision after merging
+// (note: while per-recipient headers do not make sense in compact serialization,
+// this library does not prevent you from setting them -- they are all just
+// merged into the protected headers).
+//
+// In future versions, the new behavior will be the default. New users are
+// encouraged to set this option to `false` now to avoid future issues.
+func WithLegacyHeaderMerging(v bool) EncryptOption {
+	return &encryptOption{option.New(identLegacyHeaderMerging{}, v)}
+}
+
 // WithMaxDecompressBufferSize specifies the maximum buffer size for used when
 // decompressing the payload of a JWE message. If a compressed JWE payload
 // exceeds this amount when decompressed, jwe.Decrypt will return an error.



Test patch:
diff --git a/examples/jwe_encrypt_json_example_test.go b/examples/jwe_encrypt_json_example_test.go
index 89ee1a99..6c90d347 100644
--- a/examples/jwe_encrypt_json_example_test.go
+++ b/examples/jwe_encrypt_json_example_test.go
@@ -29,7 +29,12 @@ func Example_jwe_encrypt_json() {
 	}
 
 	const payload = `Lorem ipsum`
-	encrypted, err := jwe.Encrypt([]byte(payload), jwe.WithJSON(), jwe.WithKey(jwa.RSA_OAEP(), pubkey))
+	encrypted, err := jwe.Encrypt(
+		[]byte(payload),
+		jwe.WithJSON(),                      // Toggle JSON serialization. Because there's only one key (recipient), this will produce Flattened JSON serialization
+		jwe.WithLegacyHeaderMerging(false),  // Disable legacy header merging
+		jwe.WithKey(jwa.RSA_OAEP(), pubkey), // Public key for encryption
+	)
 	if err != nil {
 		fmt.Printf("failed to encrypt payload: %s\n", err)
 		return
diff --git a/jwe/jwe_test.go b/jwe/jwe_test.go
index d8f80e98..2e7acd06 100644
--- a/jwe/jwe_test.go
+++ b/jwe/jwe_test.go
@@ -1023,3 +1023,145 @@ func TestGH1434(t *testing.T) {
 	require.NoError(t, err, `jwe.Decrypt should succeed`)
 	require.Equal(t, []byte(payload), decrypted, `decrypted payload should match original payload`)
 }
+
+type GH1470JWEFlattened struct {
+	Protected   string         `json:"protected,omitempty"`
+	Unprotected map[string]any `json:"unprotected,omitempty"`
+	Header      map[string]any `json:"header,omitempty"`
+}
+
+func TestGH1470(t *testing.T) {
+	privkey, err := jwxtest.GenerateRsaJwk()
+	require.NoError(t, err)
+
+	pubkey, err := jwk.PublicKeyOf(privkey)
+	require.NoError(t, err)
+
+	const payload = "Lorem ipsum"
+
+	TRUE := true
+	FALSE := false
+
+	t.Run("Flattened JSON serialization", func(t *testing.T) {
+		// Per-recipient unprotected header includes kid
+		recipientHeaders := jwe.NewHeaders()
+		require.NoError(t, recipientHeaders.Set("kid", "recipient1"))
+
+		recipient := jwe.WithKey(
+			jwa.RSA_OAEP_256(),
+			pubkey,
+			jwe.WithPerRecipientHeaders(recipientHeaders),
+		)
+
+		for _, lhm := range []*bool{nil, &TRUE, &FALSE} {
+			var title string
+			if lhm == nil {
+				title = "legacy header merging left as default (nil)"
+			} else if *lhm {
+				title = "legacy header merging explicitly set to true"
+			} else {
+				title = "legacy header merging explicitly set to false"
+			}
+			t.Run(title, func(t *testing.T) {
+				options := []jwe.EncryptOption{jwe.WithJSON(), recipient}
+				if lhm != nil {
+					options = append(options, jwe.WithLegacyHeaderMerging(*lhm))
+				}
+
+				// Produce JSON Serialization (flattened for single recipient)
+				encrypted, err := jwe.Encrypt([]byte(payload), options...)
+				require.NoError(t, err)
+
+				var object GH1470JWEFlattened
+				require.NoError(t, json.Unmarshal(encrypted, &object), `json.Unmarshal should succeed`)
+
+				protectedJSON, err := base64.RawURLEncoding.DecodeString(object.Protected)
+				require.NoError(t, err)
+
+				var protected map[string]any
+				require.NoError(t, json.Unmarshal(protectedJSON, &protected))
+
+				for _, key := range []string{"alg", "kid"} {
+					// Fail if the same names appear in both places (violates RFC 7516 §7.2.1)
+					if _, has := protected[key]; has {
+						_, also := object.Header[key]
+						if lhm == nil || *lhm {
+							require.True(t, also, `lhm = true, %q should exist`, key)
+						} else {
+							require.False(t, also, `lhm = false, %q should NOT exist`, key)
+						}
+					}
+				}
+			})
+		}
+	})
+
+	// one more test: we need to make sure what happens when we're using compact serialization
+	t.Run("Compact serialization", func(t *testing.T) {
+		for _, lhm := range []*bool{nil, &TRUE, &FALSE} {
+			var title string
+			if lhm == nil {
+				title = "legacy header merging left as default (nil)"
+			} else if *lhm {
+				title = "legacy header merging explicitly set to true"
+			} else {
+				title = "legacy header merging explicitly set to false"
+			}
+
+			for _, prh := range []bool{true, false} {
+				if prh {
+					title += " + per-recipient header is set"
+				} else {
+					title += " + per-recipient header is NOT set"
+				}
+
+				t.Run(title, func(t *testing.T) {
+					var options []jwe.EncryptOption
+					if prh {
+						hdr := jwe.NewHeaders()
+						_ = hdr.Set(jwe.KeyIDKey, "recipient1")
+						options = append(options, jwe.WithKey(
+							jwa.RSA_OAEP_256(),
+							pubkey,
+							jwe.WithPerRecipientHeaders(hdr),
+						))
+					} else {
+						options = append(options, jwe.WithKey(jwa.RSA_OAEP_256(), pubkey))
+					}
+
+					if lhm != nil {
+						options = append(options, jwe.WithLegacyHeaderMerging(*lhm))
+					}
+
+					_, err := jwe.Encrypt([]byte(payload), options...)
+					require.NoError(t, err, `jwe.Encrypt should succeed regardless`)
+				})
+			}
+		}
+	})
+
+	// Check for empty "`headers"`, for both parsing and serializing to
+	// flattened JSON serialization
+	t.Run("Make sure flattened JSON serialization with non-legacy header merging does not contain `headers`", func(t *testing.T) {
+		const payload = "Lorem ipsum"
+		privkey, err := jwxtest.GenerateRsaKey()
+		require.NoError(t, err, `jwxtest.GenerateRsaJwk should succeed`)
+
+		encrypted, err := jwe.Encrypt([]byte(payload), jwe.WithJSON(), jwe.WithLegacyHeaderMerging(false), jwe.WithKey(jwa.RSA_OAEP_256(), privkey.PublicKey))
+		require.NoError(t, err, `jwe.Encrypt should succeed`)
+
+		require.NotContains(t, string(encrypted), `"header":`, `flattened JSON serialization should NOT contain "header"`)
+	})
+	t.Run("Parse Without Header for Flatted Token", func(t *testing.T) {
+		token := `{
+    "ciphertext": "NR5BBCPc",
+    "encrypted_key": "PENNLqJYzXYbKgiZCTe61Qhasls8wrqGQ7ytsvvm5_E6Yya79QyrtDaX_lYm2FitLPMkw4K-63NJf1Ltp9Ur-zZUJB2rQmGkhB4toBO-SkLSkSPhUr6lILKIFWrnbBjjy63g_Awfqb7tsWT1kD0rqO_xPhhN9s0xoHMpgFYY80QQxUBVYiADiu01H_o9tsh66Npch3HAI6t6bxUfVwyMjpWK7N6k74vc95l3-ZcAGxl6aeWAtUTD37bE5bVbvILfcb6HdK0Z81fmPcRe2o9W4wZ_SP2fEV67I-jrsx73rgL1a1c_9TB4vZX3XxppvBhwKHUBAksaq3aWQXr8gkWt-g",
+    "iv": "TPrbYWe0oUeC9Eop",
+    "protected": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoicmVjaXBpZW50MSJ9",
+    "tag": "tOk33fmFy80qZVNyb22s0g"
+}`
+
+		_, err := jwe.Parse([]byte(token))
+		require.NoError(t, err)
+	})
+}
diff --git a/jwe/options_gen_test.go b/jwe/options_gen_test.go
index d0de7090..d85c3d63 100644
--- a/jwe/options_gen_test.go
+++ b/jwe/options_gen_test.go
@@ -18,6 +18,7 @@ func TestOptionIdent(t *testing.T) {
 	require.Equal(t, "WithKey", identKey{}.String())
 	require.Equal(t, "WithKeyProvider", identKeyProvider{}.String())
 	require.Equal(t, "WithKeyUsed", identKeyUsed{}.String())
+	require.Equal(t, "WithLegacyHeaderMerging", identLegacyHeaderMerging{}.String())
 	require.Equal(t, "WithMaxDecompressBufferSize", identMaxDecompressBufferSize{}.String())
 	require.Equal(t, "WithMaxPBES2Count", identMaxPBES2Count{}.String())
 	require.Equal(t, "WithMergeProtectedHeaders", identMergeProtectedHeaders{}.String())