Create a JWT in Python

jwt.encode({"sub": "user_8231", "exp": datetime.now(timezone.utc) + timedelta(hours=1)}, secret, algorithm="HS256")

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 an iat in the future. leeway=30 allows for that skew, and for an exp just 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.

Try it in the JWT Encoder

A JSON object naming the algorithm. It's signed as it stands, with the "alg" of the algorithm chosen below.

The claims to sign, as a JSON object. The registered ones — iss, sub, aud, iat, nbf, exp — are explained below.

The secret never leaves your browser: the signature is worked out here, in the page, and nothing is sent anywhere. In real use a server signs the token and keeps the secret, because anyone who holds it can make tokens that pass for yours.

Algorithm

A life stamps iat from your clock and exp that far ahead. The payload keeps any iat it already carries.

Signed at 2026-10-15T09:00:00Z, the moment the example on this page was signed, so the token below is the same one.

The token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm9cdTAwZWIiLCJhZG1pbiI6ZmFsc2UsImlhdCI6MTc5MjA1NDgwMCwiZXhwIjoxNzkyMDU4NDAwfQ.j7CZvUrAVoB9kPXsAbCflWT25kY2C5dk93uudU6Gjbc

headerpayloadsignature

38 bytes of secret, signed as HS256. HMAC with SHA: one shared secret makes the signature and checks it, so anyone who can check this token can also forge it.

Written in from the clock
  • Issued at iat

    1792054800 · 2026-10-15T09:00:00.000Z

  • Expires exp

    1792058400 · 2026-10-15T10:00:00.000Z

Claims
ClaimIn this token
sub

Subject is who the token is about, usually a user id

user_8231
iat

Issued at is when the token was made

17920548002026-10-15T09:00:00.000Z
exp

Expires is when the token stops being accepted

17920584002026-10-15T10:00:00.000Z

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