json.loads(base64.urlsafe_b64decode((p := token.split('.')[1]) + '=' * (-len(p) % 4))) reads a JWT’s payload with nothing but the standard library. It needs Python 3.8 or later for the :=. Written out:
import base64
import json
def decode_part(part: str) -> dict:
padded = part + "=" * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiZXhwIjoxODkzNDU2MDAwfQ.72UkVFWtIXY6nrOr15MKLJZHX3siVEPNHrmmE3MkQBs"
header, payload, signature = token.split(".")
decode_part(header)
# {'alg': 'HS256', 'typ': 'JWT'}
decode_part(payload)
# {'sub': 'user_8231', 'name': 'Zoë', 'admin': False, 'exp': 1893456000}
The decoder below shows the same header and payload for this token.
Why each step is there
- Padding. JWTs drop the
=padding (RFC 7515), and Python’s decoder insists on it:base64.urlsafe_b64decode(payload)raisesbinascii.Error: Incorrect padding.-len(part) % 4is how many=are missing, from 0 to 3. - The URL-safe decoder. The parts use
-and_.base64.b64decodedoesn’t read those as data: it drops them, which here leaves 85 characters andbinascii.Error: Invalid base64-encoded string: number of data characters (85) cannot be 1 more than a multiple of 4. On other tokens it returns the wrong bytes without an error. - Bytes to JSON.
json.loadstakes the decoded bytes as they are and reads them as UTF-8, soZoëcomes through without a.decode()in between.
exp, iat and nbf are seconds since 1970 in UTC. Convert them with an explicit timezone, so the result doesn’t shift with the machine’s:
from datetime import datetime, timezone
datetime.fromtimestamp(decode_part(payload)["exp"], tz=timezone.utc)
# datetime.datetime(2030, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
Decoding isn’t verifying
The signature is the only thing that ties the claims to whoever issued them. Swap in a payload with "admin": true, keep the old signature, and decode_part reads it all the same:
claims = {"sub": "user_8231", "name": "Zoë", "admin": True, "exp": 1893456000}
body = base64.urlsafe_b64encode(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode())
forged = f"{header}.{body.rstrip(b'=').decode()}.{signature}"
decode_part(forged.split(".")[1])
# {'sub': 'user_8231', 'name': 'Zoë', 'admin': True, 'exp': 1893456000}
Read claims this way for logging or debugging. Before acting on one, verify the token, with PyJWT:
import os
import jwt # pip install PyJWT
secret = os.environ["JWT_SECRET"]
jwt.decode(token, secret, algorithms=["HS256"])
# {'sub': 'user_8231', 'name': 'Zoë', 'admin': False, 'exp': 1893456000}
jwt.decode(forged, secret, algorithms=["HS256"])
# jwt.exceptions.InvalidSignatureError: Signature verification failed
algorithms= is required: without it, PyJWT 2 raises DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode(). List only the algorithms you issue. Taking the algorithm from the token’s own header is what lets alg: none and key-confusion attacks through (RFC 8725). jwt.decode also rejects a token whose exp has passed, with ExpiredSignatureError: Signature has expired.
To read a token through PyJWT without checking it, say so explicitly: jwt.decode(token, options={"verify_signature": False}).