A JSON Web Token (JWT) is a compact, URL-safe string that contains a set of claims — assertions about a subject (typically a user). The token is cryptographically signed, so the server can verify that it has not been tampered with. Unlike session-based authentication where the server stores session data, JWTs are stateless — all the information needed to identify the user is in the token itself. The server verifies the signature and reads the claims without touching a database. This makes JWTs fast and suitable for distributed systems, at the cost of not being revocable without additional infrastructure.
JWT Structure
A JWT is three Base64URL-encoded JSON objects separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header
.eyJzdWIiOiI0MiIsImV4cCI6MTc1NDE2MDAwMCwiaWF0IjoxNzU0MTU2NDAwfQ
← Payload (claims)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
Decoded Header:
{
"alg": "HS256", ← Signing algorithm (HMAC-SHA256)
"typ": "JWT"
}
Decoded Payload:
{
"sub": "42", ← Subject: user ID (always a string)
"exp": 1754160000, ← Expiration: Unix timestamp
"iat": 1754156400, ← Issued At: Unix timestamp
"jti": "a8b9c0d1...", ← JWT ID: unique token identifier
"role": "user" ← Custom claim: user role
}
Signature (HS256):
HMAC-SHA256(base64url(header) + "." + base64url(payload), SECRET_KEY)
jti (JWT ID) claim — a unique identifier for each token. Without a jti, you cannot identify and revoke specific tokens (you can only revoke all tokens by changing the secret key). With jti, you can maintain a token blacklist (in Redis or the database) for explicit revocation without invalidating all users’ tokens. The jti is also essential for detecting refresh token reuse attacks.Verifying JWT Structure in Python
import jwt
from datetime import datetime, timezone
import uuid
SECRET_KEY = "your-256-bit-random-secret-key" # from settings in production
ALGORITHM = "HS256"
# ── Create a token ────────────────────────────────────────────────────────────
def create_access_token(user_id: int, role: str, expires_in_minutes: int = 30) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id), # MUST be a string
"role": role,
"iat": int(now.timestamp()),
"exp": int((now.timestamp())) + (expires_in_minutes * 60),
"jti": str(uuid.uuid4()), # unique token ID
"type": "access",
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# ── Verify and decode a token ─────────────────────────────────────────────────
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM],
options={"verify_exp": True}, # verify expiry (default: True)
)
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")
# ── Example usage ─────────────────────────────────────────────────────────────
token = create_access_token(user_id=42, role="editor")
payload = decode_access_token(token)
print(payload["sub"]) # "42"
print(payload["role"]) # "editor"
Standard Claims Reference
| Claim | Name | Value | Required? |
|---|---|---|---|
sub |
Subject | User ID (string) | Yes — identifies the user |
exp |
Expiration | Unix timestamp (int) | Yes — when token becomes invalid |
iat |
Issued At | Unix timestamp (int) | Recommended — when token was created |
jti |
JWT ID | Unique UUID string | Recommended — enables revocation |
iss |
Issuer | URL or app name | For multi-issuer systems |
aud |
Audience | Intended recipient | For multi-service systems |
type |
Custom: token type | “access” or “refresh” | Recommended — distinguish token types |
role |
Custom: user role | “user”, “admin”, etc. | Application-specific |
Common Mistakes
Mistake 1 — Using a weak or hardcoded secret key
❌ Wrong — predictable secret, committed to version control:
SECRET_KEY = "secret" # trivially guessable — attacker can forge any token!
✅ Correct — generate a cryptographically random key:
openssl rand -hex 32 # generates a 256-bit random hex string
# Example: a3f8b1c7d2e94f0a6b5c8d3e7f1a2b4c...
Mistake 2 — Putting sensitive data in the payload
❌ Wrong — payload is readable by anyone who has the token:
payload = {"sub": str(user_id), "password_hash": user.password_hash}
# Anyone can base64-decode the payload and read the hash!
✅ Correct — include only non-sensitive identification data.
Mistake 3 — Not verifying the algorithm in jwt.decode
❌ Wrong — algorithm confusion attack (alg=none or alg switch):
payload = jwt.decode(token, SECRET_KEY) # no algorithm specified!
✅ Correct — always specify allowed algorithms:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) # ✓
Quick Reference
| Task | PyJWT Code |
|---|---|
| Install | pip install PyJWT[crypto] |
| Encode | jwt.encode(payload, key, algorithm="HS256") |
| Decode | jwt.decode(token, key, algorithms=["HS256"]) |
| Expired | except jwt.ExpiredSignatureError |
| Invalid | except jwt.InvalidTokenError |
| Generate secret | openssl rand -hex 32 |