diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4d7654d..c9df8d2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -17,6 +17,8 @@ Major
   `#121 <https://github.com/mpdavis/python-jose/pull/121>`_
 * Make pyca/cryptography backend the preferred backend if multiple backends are present.
   `#122 <https://github.com/mpdavis/python-jose/pull/122>`_
+* Allow for headless JWT by sorting headers when serializing.
+  `#136 <https://github.com/mpdavis/python-jose/pull/136>`_
 
 Bugfixes
 """"""""
diff --git a/jose/jws.py b/jose/jws.py
index b2a75fd..9d2fc0d 100644
--- a/jose/jws.py
+++ b/jose/jws.py
@@ -144,6 +144,7 @@ def _encode_header(algorithm, additional_headers=None):
     json_header = json.dumps(
         header,
         separators=(',', ':'),
+        sort_keys=True,
     ).encode('utf-8')
 
     return base64url_encode(json_header)

Use OrderedDict for headers in jwt.decode and jws._encode_header
Hello,

I would implement the "headless JWT" idea I rode in this article https://dev.to/neilmadden/7-best-practices-for-json-web-tokens the section 4 "Consider using "headless" JWTs" .

But the fact python-jose build the headers from dict make this impossible. Indeed, keys don't have an order in a classic dict. Could you consider to use OrderedDict instead, in jws.py l.131 and jwt.py l.114 ?

If this idea interests you, I could make a pull request.

Best,


diff --git a/tests/test_jwt.py b/tests/test_jwt.py
index 0140954..9ceb239 100644
--- a/tests/test_jwt.py
+++ b/tests/test_jwt.py
@@ -107,6 +107,33 @@ class TestJWT:
         for k, v in headers.items():
             assert all_headers[k] == v
 
+    def test_deterministic_headers(self):
+        from collections import OrderedDict
+        from jose.utils import base64url_decode
+
+        claims = {"a": "b"}
+        key = "secret"
+
+        headers1 = OrderedDict((
+            ('kid', 'my-key-id'),
+            ('another_key', 'another_value'),
+        ))
+        encoded1 = jwt.encode(claims, key, algorithm='HS256', headers=headers1)
+        encoded_headers1 = encoded1.split('.', 1)[0]
+
+        headers2 = OrderedDict((
+            ('another_key', 'another_value'),
+            ('kid', 'my-key-id'),
+        ))
+        encoded2 = jwt.encode(claims, key, algorithm='HS256', headers=headers2)
+        encoded_headers2 = encoded2.split('.', 1)[0]
+
+        assert encoded_headers1 == encoded_headers2
+
+        # manually decode header to compare it to known good
+        decoded_headers1 = base64url_decode(encoded_headers1.encode('utf-8'))
+        assert decoded_headers1 == b"""{"alg":"HS256","another_key":"another_value","kid":"my-key-id","typ":"JWT"}"""
+
     def test_encode(self, claims, key):
 
         expected = (

pip install -e .[cryptography]
parse_log_pytest
requirements.txt
pytest pytest-cov
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
cffi==1.17.1
coverage==7.8.0
cryptography==44.0.2
ecdsa==0.19.1
exceptiongroup==1.2.2
future==0.18.3
iniconfig==2.1.0
packaging==24.2
pluggy==1.5.0
pyasn1==0.6.1
pycparser==2.22
pycryptodome==3.22.0
pytest==8.3.5
pytest-cov==6.0.0
-e git+https://github.com/mpdavis/python-jose.git@aac4c32df210fce3da69e98822e298b3f014fe13#egg=python_jose
rsa==4.9
six==1.17.0
tomli==2.2.1

name: python-jose
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - cffi==1.17.1
      - coverage==7.8.0
      - cryptography==44.0.2
      - ecdsa==0.19.1
      - exceptiongroup==1.2.2
      - future==0.18.3
      - iniconfig==2.1.0
      - packaging==24.2
      - pluggy==1.5.0
      - pyasn1==0.6.1
      - pycparser==2.22
      - pycryptodome==3.22.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - rsa==4.9
      - six==1.17.0
      - tomli==2.2.1
prefix: /opt/conda/envs/python-jose

tests/test_jwt.py::TestJWT::test_deterministic_headers
tests/test_jwt.py::TestJWT::test_invalid_claims_json tests/test_jwt.py::TestJWT::test_invalid_claims
tests/test_jwt.py::TestJWT::test_no_alg tests/test_jwt.py::TestJWT::test_non_default_alg tests/test_jwt.py::TestJWT::test_non_default_alg_positional_bwcompat tests/test_jwt.py::TestJWT::test_no_alg_default_headers tests/test_jwt.py::TestJWT::test_non_default_headers tests/test_jwt.py::TestJWT::test_encode tests/test_jwt.py::TestJWT::test_decode tests/test_jwt.py::TestJWT::test_leeway_is_int tests/test_jwt.py::TestJWT::test_leeway_is_timedelta tests/test_jwt.py::TestJWT::test_iat_not_int tests/test_jwt.py::TestJWT::test_nbf_not_int tests/test_jwt.py::TestJWT::test_nbf_datetime tests/test_jwt.py::TestJWT::test_nbf_with_leeway tests/test_jwt.py::TestJWT::test_nbf_in_future tests/test_jwt.py::TestJWT::test_nbf_skip tests/test_jwt.py::TestJWT::test_exp_not_int tests/test_jwt.py::TestJWT::test_exp_datetime tests/test_jwt.py::TestJWT::test_exp_with_leeway tests/test_jwt.py::TestJWT::test_exp_in_past tests/test_jwt.py::TestJWT::test_exp_skip tests/test_jwt.py::TestJWT::test_aud_string tests/test_jwt.py::TestJWT::test_aud_list tests/test_jwt.py::TestJWT::test_aud_list_multiple tests/test_jwt.py::TestJWT::test_aud_list_is_strings tests/test_jwt.py::TestJWT::test_aud_case_sensitive tests/test_jwt.py::TestJWT::test_aud_empty_claim tests/test_jwt.py::TestJWT::test_aud_not_string_or_list tests/test_jwt.py::TestJWT::test_aud_given_number tests/test_jwt.py::TestJWT::test_iss_string tests/test_jwt.py::TestJWT::test_iss_list tests/test_jwt.py::TestJWT::test_iss_tuple tests/test_jwt.py::TestJWT::test_iss_invalid tests/test_jwt.py::TestJWT::test_sub_string tests/test_jwt.py::TestJWT::test_sub_invalid tests/test_jwt.py::TestJWT::test_sub_correct tests/test_jwt.py::TestJWT::test_sub_incorrect tests/test_jwt.py::TestJWT::test_jti_string tests/test_jwt.py::TestJWT::test_jti_invalid tests/test_jwt.py::TestJWT::test_at_hash tests/test_jwt.py::TestJWT::test_at_hash_invalid tests/test_jwt.py::TestJWT::test_at_hash_missing_access_token tests/test_jwt.py::TestJWT::test_at_hash_missing_claim tests/test_jwt.py::TestJWT::test_at_hash_unable_to_calculate tests/test_jwt.py::TestJWT::test_bad_claims tests/test_jwt.py::TestJWT::test_unverified_claims_string tests/test_jwt.py::TestJWT::test_unverified_claims_list tests/test_jwt.py::TestJWT::test_unverified_claims_object
CHANGELOG.rst jose/jws.py
CHANGELOG.rst jose/jws.py