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:

  1. Modify the base64 payload
  2. Recompute the signature
  3. 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:

ClaimNameMeaningExample
subSubjectIdentifies the user"42" or "alice@example.com"
expExpirationWhen it expires (Unix epoch)1714050000
iatIssued AtWhen it was issued1714049100
nbfNot BeforeValid starting from1714049100
issIssuerWho issued it"https://myapi.com"
audAudienceWho it's for"web-app" or ["web", "mobile"]
jtiJWT IDThe 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

CharacteristicSession IDJWTOpaque token
Size~32 chars200-500 bytes~32 chars
StatefulYes (server stores it)NoYes
Self-containedNo (maps to a session in the DB)Yes (info in the payload)No
RevocableTrivialComplexTrivial
Verifiable offlineNo (DB lookup)Yes (signature)No
Ideal forMonolithic appMicroservices, mobileAPI 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:

  1. You're calling jwt.decode() (not just decoding by hand)
  2. You're passing the secret correctly
  3. You don't have verify=False somewhere

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:

  1. With PyJWT (jwt.decode)
  2. 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 unnecessary
  • credit_card_token: even though it's a token, it belongs in the DB
  • total_purchases: changes often, better queried from the DB
  • shipping_addresses: a potentially long array, keep it in the DB
  • internal_customer_score: sensitive business information
  • is_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:

  1. A monolithic API that issues and validates its own tokens
  2. Multiple microservices validating tokens issued by a central Auth Service
  3. A client SDK that validates your API's tokens in the browser
  4. A mobile app + Python backend that issues tokens
See solution
  1. Monolithic API: HS256. Only your API issues and validates. A simple symmetric secret.

  2. Microservices: RS256. The Auth Service holds the private key, the other services verify with the public key. No shared secrets.

  3. 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.

  4. 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 + iat is 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

  1. RFC 7519 — JSON Web Token — The complete official specification
  2. JWT.io — Visual decoder (do NOT paste real production tokens with secrets)
  3. PyJWT Algorithms — List of supported algorithms
  4. Auth0 — JWT Anatomy — Visual tutorial of the parts
  5. IANA — JSON Web Token Claims — The official claims registry
  6. 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.