Skip to content
ecdsa.com

JWK thumbprint · Python

How to compute a JWK thumbprint (RFC 7638) in Python

Python computes a JWK thumbprint with the standard library: pick the required members of the key type, serialize them with json.dumps(sort_keys=True, separators=(",", ":")), hash with SHA-256 and base64url-encode. For PEM keys, the cryptography package supplies the key numbers. The results match the published RFC 7638 and RFC 8037 thumbprints.

Tested with CPython 3.14.8 (hashlib and json, with cryptography 50.0.2 for PEM keys).

jwk_thumbprint — RFC 7638 for EC, RSA, OKP and oct keys
import base64
import hashlib
import json
# RFC 7638: the required members of each key type
REQUIRED = {"EC": ("crv", "kty", "x", "y"), "RSA": ("e", "kty", "n"), "OKP": ("crv", "kty", "x"), "oct": ("k", "kty")}
def jwk_thumbprint(jwk: dict) -> str:
members = {name: jwk[name] for name in REQUIRED[jwk["kty"]]}
# sorted names, no whitespace, UTF-8
canonical = json.dumps(members, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
digest = hashlib.sha256(canonical).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
for name in ("ec.jwk.json", "rsa.jwk.json", "okp.jwk.json"):
with open(name) as f:
print(f"{name}: {jwk_thumbprint(json.load(f))}")
build the public JWK of a PEM key, then take its thumbprint
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_public_key
def b64url_uint(value: int, length: int = 0) -> str:
"""Big-endian, minimal length unless a fixed length is given (EC coordinates)."""
raw = value.to_bytes(length or (value.bit_length() + 7) // 8, "big")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def public_jwk(pem: bytes) -> dict:
key = load_pem_public_key(pem)
if isinstance(key, ec.EllipticCurvePublicKey):
size = (key.curve.key_size + 7) // 8 # 32 bytes for P-256, 66 for P-521
crv = {"secp256r1": "P-256", "secp384r1": "P-384", "secp521r1": "P-521"}[key.curve.name]
n = key.public_numbers()
return {"kty": "EC", "crv": crv, "x": b64url_uint(n.x, size), "y": b64url_uint(n.y, size)}
if isinstance(key, rsa.RSAPublicKey):
n = key.public_numbers()
return {"kty": "RSA", "n": b64url_uint(n.n), "e": b64url_uint(n.e)}
if isinstance(key, ed25519.Ed25519PublicKey):
raw = key.public_bytes(Encoding.Raw, PublicFormat.Raw)
return {"kty": "OKP", "crv": "Ed25519", "x": base64.urlsafe_b64encode(raw).rstrip(b"=").decode()}
raise TypeError(f"unsupported key type {type(key).__name__}")
for name in ("ec.pub.pem", "rsa.pub.pem", "okp.pub.pem"):
with open(name, "rb") as f:
print(f"{name}: {jwk_thumbprint(public_jwk(f.read()))}")

How it works

  1. REQUIRED lists the members RFC 7638 (and RFC 8037 for OKP) uses per key type; everything else in the JWK is ignored.
  2. json.dumps(…, sort_keys=True, separators=(",", ":")) produces the canonical form; ensure_ascii=False keeps any non-ASCII member value as UTF-8, as the RFC requires.
  3. hashlib.sha256(…).digest() and urlsafe_b64encode(…).rstrip(b"=") give the unpadded base64url thumbprint.
  4. For a PEM key, public_numbers() gives x/y or n/e as integers; b64url_uint turns them into the big-endian bytes a JWK carries.

Gotchas

  • json.dumps without separators writes ", " and ": " with spaces — Eou-3ifJ… instead of oKIywvGU… for the same key. Always pass separators=(",", ":").
  • EC coordinates are fixed-length (32 bytes for P-256, 66 for P-521). int.to_bytes with the minimal length drops leading zero bytes, so about one P-256 key in 128 gets a wrong x or y — and a wrong thumbprint. RSA n and e, by contrast, are minimal-length.
  • base64.urlsafe_b64encode keeps the = padding; JWK values and thumbprints never have it.
  • PyJWT's PyJWK and jwt.algorithms do not expose RFC 7638 thumbprints; computing them directly, as here, is the usual approach (authlib and jwcrypto have helpers if you already use them).

Related recipes