Decode a JWT in Python

json.loads(base64.urlsafe_b64decode((p := token.split('.')[1]) + '=' * (-len(p) % 4)))

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) raises binascii.Error: Incorrect padding. -len(part) % 4 is how many = are missing, from 0 to 3.
  • The URL-safe decoder. The parts use - and _. base64.b64decode doesn’t read those as data: it drops them, which here leaves 85 characters and binascii.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.loads takes the decoded bytes as they are and reads them as UTF-8, so Zoë 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}).

Try it in the JWT Decoder

Nothing leaves your browser: the token is decoded here, in the page, and never sent anywhere.

Valid now: it expires in 4 years.

The three parts

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiZXhwIjoxODkzNDU2MDAwfQ.72UkVFWtIXY6nrOr15MKLJZHX3siVEPNHrmmE3MkQBs

headerpayloadsignature

The algorithm is HS256: HMAC with SHA: one shared secret makes the signature and checks it, so anyone who can check this token can also forge it. Decoding only reads what the token says: nothing here checks the signature, so a token shown on this page can still be forged or altered.

header
{
  "alg": "HS256",
  "typ": "JWT"
}
payload
{
  "sub": "user_8231",
  "name": "Zoë",
  "admin": false,
  "exp": 1893456000
}
Claims
ClaimMeansIn this token
subSubject is who the token is about, usually a user iduser_8231
expExpires is when the token stops being accepted18934560002030-01-01T00:00:00.000Z

Runs in your browser. Nothing you enter is sent anywhere.