jwt.encode(claims, secret, algorithm="HS256") from PyJWT signs a JWT and returns it as a str. Unlike Node’s jsonwebtoken, PyJWT adds no times of its own: set iat and exp yourself, as timezone-aware datetimes or as ints of seconds since 1970.
from datetime import datetime, timedelta, timezone
import jwt # pip install PyJWT
secret = "quackyard-demo-secret-for-hs256-tokens" # os.environ["JWT_SECRET"] in real code
now = datetime(2026, 10, 15, 9, 0, tzinfo=timezone.utc) # datetime.now(timezone.utc) in real code
claims = {"sub": "user_8231", "name": "Zoë", "admin": False, "iat": now, "exp": now + timedelta(hours=1)}
token = jwt.encode(claims, secret, algorithm="HS256")
token
# 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm9cdTAwZWIiLCJhZG1pbiI6ZmFsc2UsImlhdCI6MTc5MjA1NDgwMCwiZXhwIjoxNzkyMDU4NDAwfQ.j7CZvUrAVoB9kPXsAbCflWT25kY2C5dk93uudU6Gjbc'
PyJWT 2.15 turns the datetimes into "iat":1792054800 and "exp":1792058400, so passing those ints gives the same token. The encoder below starts on the same header, claims, secret and moment, with a one-hour life, and shows this token byte for byte. The signature only matches while the secret, iat and exp do: press Use my clock and it changes with the times.
Why Node’s token differs
The encoder’s payload reads "name":"Zo\u00eb", because that’s what PyJWT signs: it serializes with json.dumps, which escapes every non-ASCII character by default. Node’s jsonwebtoken and jose write Zoë as UTF-8 bytes, so for the same claims at the same moment their token ends in 7Qm3NsIX…, not j7CZvUrA…. Both are valid, and every verifier reads the same claims from them. Compare claims after verifying, never tokens as strings. PyJWT also sorts the header’s keys, which gives the usual {"alg":"HS256","typ":"JWT"}.
Read it back
The JWT Decoder reads the token, at 09:00 UTC, as "name": "Zoë", iat 2026-10-15T09:00:00Z, exp 10:00:00Z and “Valid now: it expires in 1 hour.” Decoding doesn’t check the signature. Verifying does, here at 09:00 UTC:
jwt.decode(token, secret, algorithms=["HS256"])
# {'sub': 'user_8231', 'name': 'Zoë', 'admin': False, 'iat': 1792054800, 'exp': 1792058400}
What jwt.decode raises instead:
- From 10:00:00 UTC on:
ExpiredSignatureError: Signature has expired. - On a server whose clock runs ten seconds behind,
ImmatureSignatureError: The token is not yet valid (iat): PyJWT rejects aniatin the future.leeway=30allows for that skew, and for anexpjust passed. - With no
algorithms=:DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode(). - With another secret:
InvalidSignatureError: Signature verification failed.
Times: aware datetimes or ints
PyJWT converts a datetime with calendar.timegm(dt.utctimetuple()), which reads a naive datetime as UTC, whatever it meant. On a server in New York, datetime.now() at 09:00 UTC is a naive 05:00, and exp comes out as 1792040400, four hours early. datetime.utcnow() gets the number right but is deprecated since Python 3.12. Use datetime.now(timezone.utc), or int(time.time()) if you’d rather do the arithmetic in ints.
algorithm= and keys
algorithm defaults to "HS256", but name it anyway: decode needs the list. A short secret earns InsecureKeyLengthWarning: The HMAC key is 5 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2. Use 32 random bytes or more, such as secrets.token_urlsafe(32).
HS256 fits when one backend signs and checks its own tokens, since whoever can verify can also mint. When other services or partners verify, sign with a private key and hand out the public one: RS256 (RSA) or ES256 (ECDSA on P-256). An ES256 signature takes 86 base64url characters, an RS256 one with a 2048-bit key 342, and HS256’s 43. Both need pip install "PyJWT[crypto]":
from cryptography.hazmat.primitives.asymmetric import ec
private_key = ec.generate_private_key(ec.SECP256R1())
claims = {"sub": "user_8231", "exp": datetime.now(timezone.utc) + timedelta(hours=1)}
es256 = jwt.encode(claims, private_key, algorithm="ES256", headers={"kid": "key-2026-10"})
jwt.decode(es256, private_key.public_key(), algorithms=["ES256"])
The encoder below signs HS256, HS384 and HS512 only.