Decoding vs Verifying in Python
Decoding a JWT extracts the header and payload as Python dicts — it does not check authenticity. Verifying additionally recomputes the signature using the signing key and rejects any tampering, expiry, or algorithm mismatch. In Python you almost never want decode-without-verify in production; use it only for logs, tests, or debugging.
Method 1: PyJWT (Recommended)
Install
pip install PyJWT
# For RS256/ES256 signature verification:
pip install PyJWT[crypto] cryptographyDecode Without Verifying (Inspection Only)
import jwt
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# Payload as dict
payload = jwt.decode(token, options={"verify_signature": False})
print(payload)
# {'sub': '123', 'name': 'Ada', 'iat': 1516239022, 'exp': 1735689600}
# Header (algorithm, kid, etc.)
header = jwt.get_unverified_header(token)
print(header) # {'alg': 'HS256', 'typ': 'JWT'}Verify Signature (HS256 — Shared Secret)
import jwt
SECRET = "your-256-bit-secret"
try:
payload = jwt.decode(
token,
key=SECRET,
algorithms=["HS256"], # NEVER omit this — pins the allowed algorithm
audience="my-service", # optional — validates aud claim
issuer="https://auth.example.com", # optional — validates iss
)
print("Valid:", payload)
except jwt.ExpiredSignatureError:
print("Token has expired")
except jwt.InvalidSignatureError:
print("Signature verification failed — token was tampered with")
except jwt.InvalidAudienceError:
print("aud claim does not match")
except jwt.InvalidTokenError as e:
print("Invalid token:", e)Verify Signature (RS256 — Asymmetric)
import jwt
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""
payload = jwt.decode(
token,
key=PUBLIC_KEY,
algorithms=["RS256"],
audience="my-api",
)
print(payload)Verify Signature with JWKS (Auth0, Cognito, Google)
import jwt
import requests
JWKS_URL = "https://YOUR_DOMAIN/.well-known/jwks.json"
def get_signing_key(token):
header = jwt.get_unverified_header(token)
jwks = requests.get(JWKS_URL, timeout=5).json()
for k in jwks["keys"]:
if k["kid"] == header["kid"]:
return jwt.algorithms.RSAAlgorithm.from_jwk(k)
raise ValueError("No matching key found in JWKS")
payload = jwt.decode(
token,
key=get_signing_key(token),
algorithms=["RS256"],
audience="my-api",
issuer="https://YOUR_DOMAIN/",
)Method 2: Standard Library (No Dependencies)
If you cannot install PyJWT — e.g. in a minimal Lambda layer or embedded system — decode manually with base64 and json. Signature verification is out of scope without a crypto library, so this is inspection-only:
import base64
import json
def decode_jwt_payload(token: str) -> dict:
# Split on dots
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Invalid JWT format")
payload_b64 = parts[1]
# Base64URL padding — pad to a multiple of 4
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
decoded = base64.urlsafe_b64decode(padded)
return json.loads(decoded)
def decode_jwt_header(token: str) -> dict:
header_b64 = token.split(".")[0]
padded = header_b64 + "=" * (-len(header_b64) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
# Usage
payload = decode_jwt_payload(token)
header = decode_jwt_header(token)Method 3: python-jose (JWE + JWKS Bundled)
# pip install python-jose[cryptography]
from jose import jwt
payload = jwt.decode(
token,
key=PUBLIC_KEY_OR_SECRET,
algorithms=["RS256"], # or ["HS256"]
audience="my-api",
issuer="https://auth.example.com",
)Checking Expiry Manually
import jwt
import time
from datetime import datetime, timezone
decoded = jwt.decode(token, options={"verify_signature": False})
exp = decoded.get("exp")
if exp is None:
print("Token has no expiry (dangerous)")
elif exp < time.time():
print("Token expired at:", datetime.fromtimestamp(exp, tz=timezone.utc))
else:
seconds_left = int(exp - time.time())
print(f"Token expires in {seconds_left} seconds")Common Python JWT Pitfalls
- Passing
algorithms=None— Older PyJWT versions accepted any algorithm; modern versions raise. Always specify a list. - Accepting
alg: none— Never include"none"in your algorithms list. Attackers craft tokens with alg:none hoping the verifier accepts unsigned tokens. - Padding errors on urlsafe_b64decode — Base64URL strips
=padding. Always pad manually with"=" * (-len(s) % 4). - Wrong key format — HS256 wants a
strorbytessecret. RS256/ES256 want a PEM-encoded key or a cryptographyPublicKeyobject. - Not caching JWKS — Fetching JWKS on every request is slow and can cause rate limits. Cache the JWKS response for 15–60 minutes with a refresh on kid miss.
Verify + Refresh Pattern (Access + Refresh Tokens)
import jwt
from datetime import datetime, timedelta, timezone
SECRET = "your-256-bit-secret"
def make_access_token(user_id: str) -> str:
return jwt.encode({
"sub": user_id,
"iat": datetime.now(tz=timezone.utc),
"exp": datetime.now(tz=timezone.utc) + timedelta(minutes=15),
}, SECRET, algorithm="HS256")
def verify_and_get_user(token: str) -> str:
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
return payload["sub"]
except jwt.ExpiredSignatureError:
raise Exception("Token expired — call refresh endpoint")
except jwt.InvalidTokenError:
raise Exception("Invalid token — force re-login")Related Tools
- JWT Decoder Online — visual decoder for quick inspection
- Decode JWT Token — language-agnostic decoding walkthrough
- JWT Decoder in JavaScript — atob() and jose library patterns
- Verify JWT Signature — signature verification deep dive
- Base64 Encode in Python — Base64URL is the encoding JWT uses