Pragmatism in today's world

Converting JWKS JSON to PEM using Python

Following on from my earlier exploration of JWKS (RFC7517), I found myself needing to convert the JWKS into PEM format.

This time I turned to Python with my preference of using uv with inline script metadata and created jwks-to-pem.py.

The really nice thing about inline script metadata is that we can use the cryptography package to do all the hard work with RSA and serialisation. We just have to remember that the base64 encoded values are base64 URL encoded and account for it.

As a single file python script, I make it executable with chmod +x jwks-to-pem.py and made it so that I can pipe the output of a curl call to it, or pass in a JSON file. I prefer to use the curl solution though with:

curl -s https://example.com/.well-known/jwks.json | jwks-to-pem.py

Example

Here’s an example from the University of Cambridge.

On the day I wrote this article, the JWKS looks like this:

$ curl -s https://api.apps.cam.ac.uk/oauth2/v1/.well-known/jwks.json
{
  "keys": [
    {
      "alg": "RS256",
      "e": "AQAB",
      "n": "6kKjjctVPalX0ypJ2irwog8xIXS9JTABqrSnK_n3YJ4q0aH2-1bjGbWz8p1CaCUqDxQSDuqzvOgMdNGvZrxlNJ-G8hfc39jrb_KnB0T3ZsuxFz6X0mDzmHhdiPjSDK3M0syC4qg5_PB7xwKail5VWOcY0SypIYCPD6Ct5DGnQ_XONGXIVG7eaJAHdxJp2BOz0n3BVEFnZUgM5JcfGrSFfGqb0ZotX2AblwjZKQc58E0EVVykJw8gxW1Bob8rbaVXlMHssfY-9Jx0zua7ZrjO5C4OMmt9J6zYbVnGVwf62ehGtcLSP6iCG4_XM2sAMQwqJnPBss0U9WwDERk17FMHvb_FBwxAFxRygd0DclWmQmCYr5uFYck57KGARtyoxrNNAf4AFUHuObjbV24TyInYEgMhKi3SAML_4ke3dbbG-mjchXPN9OqNd4fydnQIP39WFHmFNk_nIlqvYnALI4xPE-w09T9jCvjU8hYHHlVMRvRluBnUzJkFnxLse5W-agC6ITe3wYvKH7SHVp6MYQWVD_0I2rCLV4gqjSpXzKIMs5eejjTQQq0VYumgL_f1ETvzDoewzXLOC8GGu2LZDwDbP0ea6DchReWjZfj4nJx23uQyGAj1h_uPI1jCd9oeJhbN8jFz2ltYgXYBp51qdSzsbtdec9BPPBVeXjI--c0AWU8",
      "kid": "70e0ed3c",
      "kty": "RSA",
      "use": "sig"
    }
  ]
}

They very kindly pretty-print it too!

We can then get the PEM version by piping to jwks-to-pem.py:

$ curl -s https://api.apps.cam.ac.uk/oauth2/v1/.well-known/jwks.json | jwks-to-pem.py
# Key 0 (kid: 70e0ed3c)
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA6kKjjctVPalX0ypJ2irw
og8xIXS9JTABqrSnK/n3YJ4q0aH2+1bjGbWz8p1CaCUqDxQSDuqzvOgMdNGvZrxl
NJ+G8hfc39jrb/KnB0T3ZsuxFz6X0mDzmHhdiPjSDK3M0syC4qg5/PB7xwKail5V
WOcY0SypIYCPD6Ct5DGnQ/XONGXIVG7eaJAHdxJp2BOz0n3BVEFnZUgM5JcfGrSF
fGqb0ZotX2AblwjZKQc58E0EVVykJw8gxW1Bob8rbaVXlMHssfY+9Jx0zua7ZrjO
5C4OMmt9J6zYbVnGVwf62ehGtcLSP6iCG4/XM2sAMQwqJnPBss0U9WwDERk17FMH
vb/FBwxAFxRygd0DclWmQmCYr5uFYck57KGARtyoxrNNAf4AFUHuObjbV24TyInY
EgMhKi3SAML/4ke3dbbG+mjchXPN9OqNd4fydnQIP39WFHmFNk/nIlqvYnALI4xP
E+w09T9jCvjU8hYHHlVMRvRluBnUzJkFnxLse5W+agC6ITe3wYvKH7SHVp6MYQWV
D/0I2rCLV4gqjSpXzKIMs5eejjTQQq0VYumgL/f1ETvzDoewzXLOC8GGu2LZDwDb
P0ea6DchReWjZfj4nJx23uQyGAj1h/uPI1jCd9oeJhbN8jFz2ltYgXYBp51qdSzs
btdec9BPPBVeXjI++c0AWU8CAwEAAQ==
-----END PUBLIC KEY-----

The script

This is the script in case anyone else finds it useful:

#!/usr/bin/env -S uv run --script --quiet
# /// script
# dependencies = [
#   "cryptography",
# ]
# ///

"""Convert JWK keys to PEM format.

This script reads .well-known/jwks.json and outputs PEM encoded versions
of the public keys in that file.

Usage:
    curl -s https://example.com/.well-known/jwks.json | jwks-to-pem.py
    uv run jwks-to-pem.py jwks.json
    uv run jwks-to-pem.py < jwks.json

Requirements:
    - uv (https://github.com/astral-sh/uv)
    - cryptography library

Author:
    Rob Allen 
    Copyright 2025

License:
    MIT License - https://opensource.org/licenses/MIT
"""

import json
import base64
import sys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa

def base64url_decode(data):
    """Decode base64url to bytes"""
    # Add padding if needed
    padding = 4 - len(data) % 4
    if padding != 4:
        data += '=' * padding

    # Replace URL-safe chars
    data = data.replace('-', '+').replace('_', '/')

    # Decode
    return base64.b64decode(data)

def jwk_to_pem(jwk_key):
    """Convert JWK to PEM format"""
    if jwk_key['kty'] != 'RSA':
        raise ValueError("Only RSA keys are supported")

    # Decode the modulus (n) and exponent (e) to int
    n = int.from_bytes(base64url_decode(jwk_key['n']), 'big')
    e = int.from_bytes(base64url_decode(jwk_key['e']), 'big')

    # Create RSA public key
    public_key = rsa.RSAPublicNumbers(e, n).public_key()

    # Serialize to PEM
    pem = public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
    return pem.decode()

def main():
    if len(sys.argv) > 2:
        print("Usage: jwk_to_pem.py [jwks.json]")
        print("If no file is provided, reads from stdin")
        sys.exit(1)

    if len(sys.argv) == 2 and sys.argv[1] != '-':
        # Read from file
        with open(sys.argv[1], 'r') as f:
            jwks = json.load(f)
    else:
        # Read from stdin
        jwks = json.load(sys.stdin)

    # Convert each key
    for i, key in enumerate(jwks['keys']):
        kid = key.get('kid', f'key-{i}')
        print(f"# Key {i} (kid: {kid})")
        print(jwk_to_pem(key))

if __name__ == "__main__":
    main()

Thoughts? Leave a reply

Your email address will not be published. Required fields are marked *