Skip to content

How to sign JWTs with ML-DSA in Python

Use pqblocks-jose to sign a JWT-style JWS with a hybrid ML-DSA-65 + Ed25519 signature and verify it. The token stays valid unless an attacker can forge both the post-quantum and the classical signature.

The problem

JWTs are usually signed with RS256 (RSA) or ES256 (ECDSA). Shor's algorithm breaks both, so an attacker with a cryptographically relevant quantum computer could forge tokens for any user. Unlike encrypted data, signatures cannot be "harvested now" and broken later, but long-lived keys and trust anchors need to move before such a machine exists, and verifiers need time to roll out. ML-DSA (FIPS 204) is the NIST standard replacement. Pairing it with Ed25519 keeps tokens safe even if one of the two schemes turns out to be weaker than expected.

Install

pip install git+https://github.com/PQBlocks/hybrid-jose
uv add git+https://github.com/PQBlocks/hybrid-jose

pqblocks-jose uses liboqs for ML-DSA and ML-KEM; see the package page for the native library.

Complete working code

jwt_ml_dsa.py
"""Sign a JWT-style token with hybrid ML-DSA-65 + Ed25519 and verify it (pqblocks-jose)."""
from pqblocks_jose import Policy, decode_jws, encode_jws, generate_keypair

# The policy pins every algorithm choice; a token that disagrees with it is rejected.
policy = Policy(
    version=1,
    kem="X25519+MLKEM768",
    sig="MLDSA65+Ed25519",
    kdf="HKDF-SHA256",
    max_token_bytes=8192,
    kdf_info="JOSE_PostQuantum_Traditional_Hybrid X25519MLKEM768",
    sig_alg_std="ML-DSA-65-Ed25519",
)

# No seed: the key comes from os.urandom. Seeds exist for reproducible tests only.
keys = generate_keypair("ML-DSA-65-Ed25519")

claims = {"sub": "alice@example.com", "aud": "api.example.com", "iat": 1700000000}
token = encode_jws(claims, policy, private_key=keys.private_key).compact_serialization

result = decode_jws(token=token, policy=policy, public_key=keys.public_key)
assert result.signature_verified
print("verified:", result.signature_verified, "| token bytes:", len(token))

# A different key must not verify the token.
other = generate_keypair("ML-DSA-65-Ed25519")
try:
    forged = decode_jws(token=token, policy=policy, public_key=other.public_key)
    assert not forged.signature_verified
except Exception as exc:  # the library may raise instead of returning False
    print("wrong key rejected:", type(exc).__name__)

Output:

verified: True | token bytes: 4645
wrong key rejected: InvalidTokenError

How it works

  1. The policy pins the algorithms. Policy names the signature (MLDSA65+Ed25519), the KEM, the KDF and a maximum token size. A token whose header asks for anything else is refused, which closes the classic JWT alg confusion attacks.
  2. One key pair, two schemes. generate_keypair("ML-DSA-65-Ed25519") returns a composite key: an Ed25519 key and an ML-DSA-65 key, concatenated. Without a seed argument the key material comes from os.urandom.
  3. Both signatures must verify. decode_jws checks the ML-DSA-65 signature and the Ed25519 signature. If either fails, the token is rejected.

Things to know

Tokens get bigger

An ML-DSA-65 signature is 3,309 bytes, so a signed token is about 4.6 KB here, against a few hundred bytes for ES256. Check header-size limits on proxies and load balancers (max_token_bytes enforces your own limit).

  • Never pass a seed in production. Seeds exist so tests are reproducible.
  • Keep the private key in a secret store or HSM and load it at start-up; the recipe generates one in memory only to stay self-contained.
  • pqblocks-jose is pre-1.0 and not independently audited. Read its security notes.