RS256 JWTs with PyJWT and cryptography-generated keys: working versions (tested 2026-10-15)
Generate an RSA keypair with cryptography, sign RS256 JWTs with PyJWT, and verify them with a public key while enforcing algorithm, audience, expiry and signature checks, all tested offline.
passed6 passed · 0 failed2026-10-15 13:32 UTC- Status
- passed
- Last passed
- 2026-10-15 13:32 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pyjwt-cryptography-rs256") (install), or see them on GitHub.
pyjwt==2.15.1 cryptography==50.0.2 pytest==9.1.1
from datetime import datetime, timedelta, timezone
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def generate_keypair() -> tuple[bytes, bytes]:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return private_pem, public_pem
def issue_token(private_pem: bytes, subject: str, audience: str, ttl: int = 300, kid: str | None = None) -> str:
now = datetime.now(timezone.utc)
payload = {"sub": subject, "aud": audience, "iat": now, "exp": now + timedelta(seconds=ttl)}
headers = {"kid": kid} if kid else None
# PyJWT 2.x returns str (not bytes) and converts datetime claims to integer timestamps
return jwt.encode(payload, private_pem, algorithm="RS256", headers=headers)
def verify_token(public_pem: bytes, token: str, audience: str) -> dict:
# Always pass an explicit algorithm allowlist, never the key's type alone
return jwt.decode(
token,
public_pem,
algorithms=["RS256"],
audience=audience,
options={"require": ["exp", "iat", "sub"]},
)
import base64
import json
import jwt
import pytest
from auth import generate_keypair, issue_token, verify_token
@pytest.fixture(scope="module")
def keys():
return generate_keypair()
def _b64(obj) -> str:
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
def test_roundtrip_rs256(keys):
priv, pub = keys
token = issue_token(priv, "alice", "api", kid="k1")
assert isinstance(token, str)
assert jwt.get_unverified_header(token) == {"alg": "RS256", "typ": "JWT", "kid": "k1"}
assert verify_token(pub, token, "api")["sub"] == "alice"
def test_audience_is_enforced(keys):
priv, pub = keys
token = issue_token(priv, "alice", "api")
# A token carrying "aud" is rejected when the verifier does not expect one
with pytest.raises(jwt.InvalidAudienceError):
jwt.decode(token, pub, algorithms=["RS256"])
with pytest.raises(jwt.InvalidAudienceError):
verify_token(pub, token, "other")
def test_expired_token_rejected(keys):
priv, pub = keys
token = issue_token(priv, "alice", "api", ttl=-5)
with pytest.raises(jwt.ExpiredSignatureError):
verify_token(pub, token, "api")
def test_unsigned_none_token_rejected(keys):
_, pub = keys
token = f"{_b64({'alg': 'none', 'typ': 'JWT'})}.{_b64({'sub': 'mallory', 'aud': 'api'})}."
with pytest.raises(jwt.InvalidAlgorithmError):
verify_token(pub, token, "api")
def test_algorithms_argument_is_required(keys):
priv, pub = keys
token = issue_token(priv, "alice", "api")
# Omitting the allowlist lets the library choose the algorithm, which is unsafe
with pytest.raises(jwt.DecodeError):
jwt.decode(token, pub, audience="api")
def test_token_from_foreign_key_rejected(keys):
_, pub = keys
other_priv, _ = generate_keypair()
forged = issue_token(other_priv, "mallory", "api")
with pytest.raises(jwt.InvalidSignatureError):
verify_token(pub, forged, "api")
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI + PyJWT bearer auth
OAuth2 password flow in FastAPI issuing PyJWT tokens, with a current-user dependency guarding routes.
FastAPI OAuth2 password flow with PyJWT tokens and scope checks
OAuth2 password login issuing PyJWT access tokens, Argon2 hashes via pwdlib, and per-route scope checks with SecurityScopes, tested offline with TestClient.