JWT Fundamentals — Structure, Claims and Signing

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)
Note: The JWT payload is Base64URL-encoded, not encrypted — anyone can decode and read the claims without knowing the secret key. This is by design: JWTs are integrity-protected (tamper-evident via the signature) but not confidential. Never put sensitive information in a JWT payload (passwords, full PII, credit card numbers). The payload is safe to include: user ID, role, email address for display, token expiry. It is not safe to include: password hashes, secrets, private keys.
Tip: Always include the 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.
Warning: HS256 uses a single shared secret — the same key is used to both sign and verify tokens. Any service with the secret can issue tokens, not just your authentication server. For microservices where you want only one service to issue tokens and others to only verify them, use RS256 (asymmetric): the auth server signs with a private key; other services verify with the corresponding public key. For a single FastAPI application, HS256 is simpler and sufficient.

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

🧠 Test Yourself

A client has a JWT with {"sub": "42", "role": "admin", "exp": 9999999999}. They change "role" to "admin" (it already is admin), re-encode the payload with Base64, and send the modified token. What happens when the server verifies it?