import base64import hashlibimport json # RFC 7638: the required members of each key typeREQUIRED = {"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))}")from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsafrom 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
REQUIREDlists the members RFC 7638 (and RFC 8037 for OKP) uses per key type; everything else in the JWK is ignored.json.dumps(…, sort_keys=True, separators=(",", ":"))produces the canonical form;ensure_ascii=Falsekeeps any non-ASCII member value as UTF-8, as the RFC requires.hashlib.sha256(…).digest()andurlsafe_b64encode(…).rstrip(b"=")give the unpadded base64url thumbprint.- For a PEM key,
public_numbers()gives x/y or n/e as integers;b64url_uintturns them into the big-endian bytes a JWK carries.
Gotchas
json.dumpswithoutseparatorswrites", "and": "with spaces —Eou-3ifJ…instead ofoKIywvGU…for the same key. Always passseparators=(",", ":").- EC coordinates are fixed-length (32 bytes for P-256, 66 for P-521).
int.to_byteswith the minimal length drops leading zero bytes, so about one P-256 key in 128 gets a wrongxory— and a wrong thumbprint. RSAnande, by contrast, are minimal-length. base64.urlsafe_b64encodekeeps the=padding; JWK values and thumbprints never have it.- PyJWT's
PyJWKandjwt.algorithmsdo not expose RFC 7638 thumbprints; computing them directly, as here, is the usual approach (authlib and jwcrypto have helpers if you already use them).