Module 2: JWT Tokens — Introduction
Capsule 02: Anatomy of a JWT — Header, Payload, Signature
Overview
A JWT looks cryptic the first time you run into one: a long string of characters split by dots. But internally it's something simple — three parts with clear purposes, all of them readable if you know how to read them.
In this capsule you'll open a real JWT, take it apart into its three pieces (header, payload, signature) with Python code, and understand why each part matters. You'll learn the critical concept that confuses almost everyone: the payload is NOT encrypted, it's only base64 encoded. After this capsule, when you see a JWT, you'll know exactly what it is.
What a JWT looks like
Here's a real JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcxNDA1MDAwMCwiaWF0IjoxNzE0MDQ5MTAwfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
At first glance it looks random. But look closer:
eyJhbGciOiJIUzI1NiIs... ← part 1 (HEADER)
.
eyJzdWIiOiI0MiIs... ← part 2 (PAYLOAD)
.
dBjftJeZ4CVP-mB92K27u... ← part 3 (SIGNATURE)
Three sections separated by dots. Each one has a specific purpose.
The three parts
Part 1 — Header
It holds metadata about the token. Specifically:
alg: signing algorithm (HS256, RS256, etc.)typ: token type (always "JWT")
In the decoded example:
{
"alg": "HS256",
"typ": "JWT"
}
Part 2 — Payload (also called "claims")
It holds the information the token carries. There are standard claims (defined in the RFC) and custom claims (the ones you add).
In the decoded example:
{
"sub": "42",
"exp": 1714050000,
"iat": 1714049100
}
sub: subject (typically the user ID)exp: expiration timestamp (Unix epoch)iat: issued at timestamp
Part 3 — Signature
The cryptographic signature that guarantees the token has NOT been modified. It's computed like this:
signature = HMAC_SHA256(
base64(header) + "." + base64(payload),
secret_key
)
The secret_key is what separates your system from a compromised one. Without it, nobody can generate valid signatures.
Seeing it with code
import json
import base64
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcxNDA1MDAwMCwiaWF0IjoxNzE0MDQ5MTAwfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
# Split the three parts
header_b64, payload_b64, signature = token.split(".")
def b64_decode(data: str) -> bytes:
"""base64url decode with the right padding."""
padding = 4 - (len(data) % 4)
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
# Decode the header
header_json = b64_decode(header_b64).decode()
print("HEADER:", json.loads(header_json))
# Decode the payload
payload_json = b64_decode(payload_b64).decode()
print("PAYLOAD:", json.loads(payload_json))
# The signature stays as bytes (it isn't JSON)
signature_bytes = b64_decode(signature)
print("SIGNATURE:", signature_bytes.hex())
Output:
HEADER: {'alg': 'HS256', 'typ': 'JWT'}
PAYLOAD: {'sub': '42', 'exp': 1714050000, 'iat': 1714049100}
SIGNATURE: 7418e2fb479a785c2535...
What you just learned: anyone holding the token can read the header and payload without the secret. All they need is a base64 decoder.
The critical concept: the payload is NOT encrypted
This confuses almost every developer the first time. Say it out loud:
"A JWT's payload is base64 encoded, NOT encrypted. Anyone with the token can read it."
Practical implications:
# ❌ WRONG — exposing sensitive info
payload = {
"sub": "42",
"credit_card": "4111-1111-1111-1111", # Visible!
"internal_admin_flag": True, # Visible!
"next_password_to_use": "P@ssw0rd" # VISIBLE!
}
token = jwt.encode(payload, secret, algorithm="HS256")
# Anyone with the token can:
# 1. Copy it and paste it into jwt.io
# 2. See every bit of the payload without needing the secret
What IS safe to put in the payload:
sub(user ID — public to the user themselves)exp,iat,nbf(timestamps)- Roles (
role,permissions— the user already knows them) - Email, name (info the user already has about themselves)
What you must NOT put:
- Passwords (in any form)
- Card numbers
- Sensitive personal data (SSN, medical info)
- API keys
- Third-party service tokens
- Anything that reveals internal privileges the user isn't meant to see
Mental rule: if the user opened their browser, copied the token and pasted it into jwt.io, would they see something they shouldn't? If yes → don't put it there.
So what makes a JWT trustworthy?
The signature. Without the secret key, nobody can generate a valid signature. So nobody can:
- Create new tokens pretending to be your API
- Modify the payload of an existing token (the signature would stop matching)
The validation flow:
1. Your API receives the token
2. It takes header + payload (in base64)
3. It computes HMAC_SHA256(header + "." + payload, SECRET_KEY)
4. It compares that with the token's signature
5. If they match → valid token
6. If not → modified/forged token → reject
If someone tries to modify the payload (e.g. change "sub": "42" to "sub": "1" to impersonate an admin), they'd have to:
- Modify the base64 payload
- Recompute the signature
- But to recompute the signature they need the SECRET_KEY
Without the secret, they can't produce a valid signature. The new token would be rejected.
Demo: why the secret matters
import jwt
SECRET = "my-strong-secret-of-32-bytes-minimum"
# Generate a token with the right secret
payload = {"sub": "42", "role": "user"}
token = jwt.encode(payload, SECRET, algorithm="HS256")
print("Token:", token)
# Validate with the right secret → OK
decoded = jwt.decode(token, SECRET, algorithms=["HS256"])
print("Decoded:", decoded)
# Try validating with a different secret → FAILS
try:
jwt.decode(token, "wrong-secret", algorithms=["HS256"])
except jwt.InvalidSignatureError:
print("ERROR: Bad signature — token rejected")
Output:
Token: eyJhbGciOiJIUzI1Ni...
Decoded: {'sub': '42', 'role': 'user'}
ERROR: Bad signature — token rejected
Now let's simulate an attacker trying to modify the payload:
import json
import base64
# Original token
token = jwt.encode({"sub": "42", "role": "user"}, SECRET, algorithm="HS256")
print("Original:", token)
# The attacker decodes the payload
header_b64, payload_b64, sig = token.split(".")
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "==").decode())
print("Original payload:", payload)
# The attacker changes role to admin
payload["role"] = "admin"
new_payload_b64 = base64.urlsafe_b64encode(
json.dumps(payload).encode()
).rstrip(b"=").decode()
# The attacker assembles the modified token (without touching the signature)
malicious_token = f"{header_b64}.{new_payload_b64}.{sig}"
print("Malicious:", malicious_token)
# They try to use it
try:
decoded = jwt.decode(malicious_token, SECRET, algorithms=["HS256"])
print("ATTACK SUCCEEDED:", decoded)
except jwt.InvalidSignatureError:
print("ATTACK BLOCKED: The signature no longer matches")
Output:
Original: eyJhbGciOiJIUzI1Ni...
Original payload: {'sub': '42', 'role': 'user'}
Malicious: eyJhbGciOiJIUzI1Ni...
ATTACK BLOCKED: The signature no longer matches
The attacker can modify the payload, but without the secret they can't produce the new valid signature. The system rejects the token.
Standard JWT claims (RFC 7519)
The RFC defines several claims with universal meaning. The most used ones:
| Claim | Name | Meaning | Example |
|---|---|---|---|
sub | Subject | Identifies the user | "42" or "alice@example.com" |
exp | Expiration | When it expires (Unix epoch) | 1714050000 |
iat | Issued At | When it was issued | 1714049100 |
nbf | Not Before | Valid starting from | 1714049100 |
iss | Issuer | Who issued it | "https://myapi.com" |
aud | Audience | Who it's for | "web-app" or ["web", "mobile"] |
jti | JWT ID | The token's unique ID | "abc-123" |
You don't have to use them all. For a typical system, sub + exp + iat is enough.
Custom claims
You can add any field you want:
payload = {
# Standard
"sub": "42",
"exp": 1714050000,
"iat": 1714049100,
# Custom
"role": "editor",
"email": "alice@example.com",
"permissions": ["read:posts", "write:posts"],
}
Conventions to avoid collisions:
- For claims private to your app, prefix them with your domain:
"https://myapi.com/role": "admin"(IANA-registered convention) - For small internal apps, simple names (
role,permissions) are fine
Comparison: three token formats
| Characteristic | Session ID | JWT | Opaque token |
|---|---|---|---|
| Size | ~32 chars | 200-500 bytes | ~32 chars |
| Stateful | Yes (server stores it) | No | Yes |
| Self-contained | No (maps to a session in the DB) | Yes (info in the payload) | No |
| Revocable | Trivial | Complex | Trivial |
| Verifiable offline | No (DB lookup) | Yes (signature) | No |
| Ideal for | Monolithic app | Microservices, mobile | API gateway pattern |
JWT shines when: several services need to validate tokens without coordinating with each other. Sessions shine when: immediate revocation is critical and you have a single backend.
Signing algorithms: HS256 vs RS256
JWT supports several algorithms. The two most common:
HS256 (HMAC-SHA256) — symmetric
- A single key (the secret) used both to sign and to verify
- Whoever signs === whoever verifies
- Ideal when your API issues AND validates its own tokens
- Simpler
- What you'll use in this guide
RS256 (RSA-SHA256) — asymmetric
- Two keys: a private key (signs), a public key (verifies)
- Multiple services can verify with the public key without access to the private one
- Ideal when a central IdP issues tokens and many services validate them
- More complex (key management, key rotation)
For an API that issues and validates its own tokens (your case): HS256.
For architectures with a central IdP + multiple consumers: RS256.
Troubleshooting
Problem 1: "I pasted my token into jwt.io and I see sensitive info"
That's because you put it there. The payload is public to whoever holds the token. Fix: don't put sensitive info in the payload.
Problem 2: "My token is 800 bytes — is that a lot?"
Yes. You're probably cramming too much info into the payload. Consider:
- Only putting
sub(user_id) and the bare minimum - Loading extra info from the DB when you need it
- Trade-off: smaller tokens = more DB lookups; bigger tokens = more bandwidth
Aim for tokens in the 200-400 byte range.
Problem 3: "I decoded the payload and exp is a weird number"
It's a Unix timestamp (seconds since 1970). To convert it:
from datetime import datetime, timezone
exp = 1714050000
print(datetime.fromtimestamp(exp, tz=timezone.utc))
# 2024-04-25 13:00:00+00:00
Problem 4: "My JWT ends with =, is that OK?"
Some generators add = padding, others don't. base64 padding is optional. Both eyJxxx= and eyJxxx are equivalent and PyJWT accepts either.
Problem 5: "I modified the payload by hand and the token still works"
Impossible if your validation is done right. Check that:
- You're calling
jwt.decode()(not just decoding by hand) - You're passing the secret correctly
- You don't have
verify=Falsesomewhere
Exercises
Exercise 1: Decode a JWT by hand
Decode this JWT into its three parts without using PyJWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
See solution
import json
import base64
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
def b64_decode(data: str) -> bytes:
padding = 4 - (len(data) % 4)
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
header_b64, payload_b64, sig_b64 = token.split(".")
print("HEADER:", json.loads(b64_decode(header_b64)))
print("PAYLOAD:", json.loads(b64_decode(payload_b64)))
print("SIGNATURE:", b64_decode(sig_b64).hex())
Output:
HEADER: {'alg': 'HS256', 'typ': 'JWT'}
PAYLOAD: {'sub': '1234567890', 'name': 'John Doe', 'iat': 1516239022}
SIGNATURE: 4a594b6c30e44c89c44a5b9043e7e0a4c5e89f7e8f3a4e8b...
Exercise 2: Create a JWT and decode it two ways
Generate a JWT with PyJWT containing: sub=42, email="bob@test.com", role="editor". Decode it:
- With PyJWT (
jwt.decode) - By hand (without using the secret)
Confirm both give you the same payload.
See solution
import jwt
import json
import base64
SECRET = "my-secret-test-32-chars-min!!!!!"
# Create the token
payload = {"sub": "42", "email": "bob@test.com", "role": "editor"}
token = jwt.encode(payload, SECRET, algorithm="HS256")
print("Token:", token)
# Method 1: PyJWT
decoded_pyjwt = jwt.decode(token, SECRET, algorithms=["HS256"])
print("\nDecoded with PyJWT:", decoded_pyjwt)
# Method 2: By hand (no secret)
def b64_decode(data: str) -> bytes:
padding = 4 - (len(data) % 4)
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
_, payload_b64, _ = token.split(".")
decoded_manual = json.loads(b64_decode(payload_b64))
print("Decoded by hand:", decoded_manual)
# Confirm they're the same
assert decoded_pyjwt == decoded_manual
print("\n✓ Both methods give the same payload")
Insight: the manual method does NOT need the secret. Anyone can read the payload.
Exercise 3: Prove the signature protects the token
Generate a token, try modifying the payload by hand, decode it again with PyJWT and watch the error.
See solution
import jwt
import json
import base64
SECRET = "my-secret-test"
# Original token
token = jwt.encode({"sub": "42", "role": "user"}, SECRET, algorithm="HS256")
# Modify the payload by hand
header_b64, payload_b64, sig = token.split(".")
def b64_decode(data: str) -> bytes:
padding = 4 - (len(data) % 4)
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
def b64_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
payload = json.loads(b64_decode(payload_b64))
payload["role"] = "admin" # Privilege escalation attempt
new_payload_b64 = b64_encode(json.dumps(payload).encode())
malicious_token = f"{header_b64}.{new_payload_b64}.{sig}"
print("Original token:", token[:50], "...")
print("Malicious:", malicious_token[:50], "...")
# Validation attempt
try:
decoded = jwt.decode(malicious_token, SECRET, algorithms=["HS256"])
print(f"⚠️ Token accepted: {decoded}")
except jwt.InvalidSignatureError as e:
print(f"✓ Token rejected: {e}")
Output:
Original token: eyJhbGciOiJIUzI1Ni...
Malicious: eyJhbGciOiJIUzI1Ni...
✓ Token rejected: Signature verification failed
Lesson: modifying the payload also requires modifying the signature, which requires the secret.
Exercise 4: Design the payload
Design the JWT payload for an e-commerce app. The user is a shopper. What would you put in, and what would you leave out?
See solution
DO put:
{
"sub": "user_42", # User ID
"exp": 1714050000, # Expiration
"iat": 1714049100, # Issued at
"email": "alice@example.com", # To display in the UI
"role": "customer", # Basic role
"verified_email": True, # Whether it's verified
}
Do NOT put:
password_hash: irrelevant and unnecessarycredit_card_token: even though it's a token, it belongs in the DBtotal_purchases: changes often, better queried from the DBshipping_addresses: a potentially long array, keep it in the DBinternal_customer_score: sensitive business informationis_vip_secret_program: internal privileges not exposed to the user
Rule: small payload, stable info, info the user already knows.
Exercise 5: HS256 vs RS256 — the decision
For each scenario, pick the algorithm:
- A monolithic API that issues and validates its own tokens
- Multiple microservices validating tokens issued by a central Auth Service
- A client SDK that validates your API's tokens in the browser
- A mobile app + Python backend that issues tokens
See solution
-
Monolithic API: HS256. Only your API issues and validates. A simple symmetric secret.
-
Microservices: RS256. The Auth Service holds the private key, the other services verify with the public key. No shared secrets.
-
Browser SDK: RS256. The server signs with the private key, the browser verifies with the public key. A symmetric key (HS256) in the browser would be a security risk — anyone could inspect the JS and grab it.
-
Mobile + monolithic backend: HS256. Mobile shouldn't be verifying tokens (the backend does that). The secret lives only on the server.
Pattern: HS256 when a single place signs AND verifies. RS256 when they're different places.
Summary
- A JWT has 3 parts: header (alg, typ), payload (claims), signature (HMAC).
- The 3 parts are separated by dots:
xxx.yyy.zzz. - The payload is NOT encrypted, it's just base64. Anyone with the token can read it.
- The signature is what makes the token trustworthy. Without the secret, nobody can generate valid signatures.
- Standard claims:
sub,exp,iat,nbf,iss,aud,jti. For typical apps,sub+exp+iatis enough. - Do NOT put sensitive info in the payload (passwords, private data, API keys).
- HS256 vs RS256: symmetric (same secret) vs asymmetric (private/public keys). For your API, which issues AND validates its own tokens, HS256.
- Mental rule: if you pasted your token into jwt.io, would you see anything problematic? If yes → don't put it there.
Additional resources
- RFC 7519 — JSON Web Token — The complete official specification
- JWT.io — Visual decoder (do NOT paste real production tokens with secrets)
- PyJWT Algorithms — List of supported algorithms
- Auth0 — JWT Anatomy — Visual tutorial of the parts
- IANA — JSON Web Token Claims — The official claims registry
- Cryptography Stack Exchange — Why HS256 — Technical explanation of HS256
Next step
Capsule 03: Generating tokens with PyJWT. You'll implement create_access_token() with the right claims, handle exp with timezone-aware datetimes, and walk out with real code for your auth system.