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¶
pqblocks-jose uses liboqs for ML-DSA and ML-KEM; see the package page for the native library.
Complete working code¶
"""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:
How it works¶
- The policy pins the algorithms.
Policynames 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 JWTalgconfusion attacks. - 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 aseedargument the key material comes fromos.urandom. - Both signatures must verify.
decode_jwschecks 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
seedin 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-joseis pre-1.0 and not independently audited. Read its security notes.
Related¶
- How to add post-quantum tokens to FastAPI
pqblocks-josepackage pagepqblocks-jwe-audit: audit which JOSE algorithms your gateway accepts