ecdsa.com

Verify an ES256 JWT · Python

How to verify an ES256 JWT in Python

Verifying an ES256 JWT in Python with the cryptography package means bridging two conventions: JWS ships a raw 64-byte r‖s signature, while cryptography's verify wants DER. encode_dss_signature is the bridge — the rest is base64url and JSON handling.

Tested with Python 3.14 and cryptography 50.0.

Same recipe in:Node.jsPythonGoWebCrypto
verify_jwt.py — ES256 verification with pyca/cryptography
import base64
import json
import time
 
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.hazmat.primitives.serialization import load_pem_public_key
 
 
def b64url_decode(part: str) -> bytes:
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
 
 
def verify_es256(token: str, public_key_pem: bytes) -> dict:
header_b64, payload_b64, sig_b64 = token.split(".")
 
header = json.loads(b64url_decode(header_b64))
if header["alg"] != "ES256":
raise ValueError(f"expected ES256, got {header['alg']}")
 
sig = b64url_decode(sig_b64) # 64 raw bytes: r ‖ s
der = encode_dss_signature(
int.from_bytes(sig[:32], "big"),
int.from_bytes(sig[32:], "big"),
)
 
key = load_pem_public_key(public_key_pem)
signed = f"{header_b64}.{payload_b64}".encode("ascii")
key.verify(der, signed, ec.ECDSA(hashes.SHA256())) # raises InvalidSignature
 
claims = json.loads(b64url_decode(payload_b64))
if "exp" in claims and claims["exp"] < time.time():
raise ValueError("token expired")
return claims

How it works

  1. JWS base64url comes without padding; re-add = to a multiple of four before urlsafe_b64decode or it raises.
  2. Pin the algorithm: read alg and require ES256 — never dispatch on whatever the token claims.
  3. Rebuild DER from the two 32-byte halves with encode_dss_signature; verify then works exactly as for any ECDSA signature.
  4. The signed bytes are the ASCII text header.payload — encode the joined base64url strings, don't re-serialize the JSON.

Gotchas

  • Feeding the 64 raw bytes straight into verify raises InvalidSignature every time — the missing DER conversion is the number-one reason "valid tokens" fail in Python.
  • json.loads then json.dumps then re-encode is not a round trip: key order and whitespace change, and the signature is over the original bytes. Always verify against the token's own text.
  • PyJWT does all of this for you (jwt.decode(token, key, algorithms=["ES256"])) — use it in production; this recipe is for when you need the mechanism or can't add the dependency.

Related recipes