PythonMastery
intermediate 22 min read · lesson 2 of 4 in Auth & Security

JWT & Bearer Tokens

1 · The lesson

read

Examples shown in Flask/FastAPI shape. Run locally with the framework installed; auth flows need a running server. Expected output shown in comments.

A JWT (JSON Web Token) is a compact, URL-safe, signed string that carries claims about a user. The server signs it on login, the client sends it back on every request, the server verifies the signature and trusts the claims — without looking anything up in a database. That's the appeal: stateless authentication that scales horizontally without a shared session store.

It's also the auth primitive most likely to be used wrong. Algorithm-confusion CVEs, payloads mistaken for encrypted, tokens that can't be revoked. This lesson is how to use JWT correctly — and, just as important, when to reach for session cookies instead.


1. The Structure — Three Base64 Chunks

A JWT is header.payload.signature — three base64url-encoded strings joined by dots.

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcxNTY4ODAwMH0.abc123_signature_bytes
└──────────── header ────────────┘ └─────── payload ───────┘ └── signature ──┘

Decoded:

json
// header
{"alg": "HS256", "typ": "JWT"}

// payload (claims)
{"sub": "42", "exp": 1715688000}

// signature
HMAC_SHA256(
    base64url(header) + "." + base64url(payload),
    secret_key
)

The header and payload are base64-encoded, not encrypted — anyone with the token can decode and read them. The signature is what guarantees they haven't been tampered with: change one byte of the payload, the signature no longer matches, the server rejects the token.

This is the most important sentence in this lesson: JWT payloads are signed, not encrypted. Treat them as public. Never put a password, an API key, or anything you wouldn't print on a postcard inside one.


2. Why JWT — And Why Not

Choose JWT when:

  • You have multiple backend services and a shared secret/public key — any service can validate without a DB call.
  • You want stateless auth that horizontally scales without a shared session store.
  • You're building a public API where clients send the token on every request.

Skip JWT when:

  • It's a single web app with cookies — server-side sessions (covered in auth-passwords) are simpler, revocable, and don't require token-rotation gymnastics.
  • You need immediate revocation. JWTs are valid until they expire; "log this user out everywhere right now" is hard without a denylist (and the denylist defeats the stateless win).
  • You don't have a real reason. "Because it's modern" is not one.

The honest pitch in 2026: JWTs solved the cross-service auth problem; they did not solve the single-app login problem. A surprising amount of JWT code in the wild is "what session cookies would have done, but with extra steps and a CVE."


3. Standard Claims — The Three-Letter Vocabulary

RFC 7519 defines a set of standard claims. Use them — libraries already validate them automatically.

ClaimMeaningExample
subSubject — who the token is about.User ID.
issIssuer — who minted the token."https://auth.myapp.com"
audAudience — who's allowed to consume it."https://api.myapp.com"
expExpiry — Unix timestamp after which it's invalid.1715688000
iatIssued at — Unix timestamp when minted.1715684400
nbfNot before — invalid until this timestamp.1715684400
jtiJWT ID — unique ID for the token (for denylisting).UUID

You can add custom claims freely ("role": "admin", "email": "...") — just remember they're readable by anyone with the token.


4. Signing — HS256 vs RS256

Two algorithm families cover ~99% of real JWT usage.

HS256 — HMAC with a shared secret.

  • One key, used to both sign and verify.
  • Simple. Fast. Right for single-service apps and tightly-coupled services that already share secrets.
  • Risk: every service that can verify can also forge. Don't hand the key out to clients.

RS256 — RSA with a public/private key pair.

  • Auth server signs with the private key; everyone else verifies with the public key.
  • Right for federated/multi-service setups where you don't want every downstream service to be able to mint tokens.
  • The public key is published (sometimes via a JWKS endpoint), the private key is held by the issuer only.

There are more (ES256 — elliptic curve; EdDSA — newer, faster), but HS256 for monoliths and RS256 for federations is the standard split.


5. HS256 in Practice with PyJWT

bash
pip install pyjwt
python
import jwt
import time

SECRET = "long-random-string-from-environment"      # at least 32 bytes; env var in real life

payload = {
    "sub": "user-42",
    "iat": int(time.time()),
    "exp": int(time.time()) + 15 * 60,              # 15 minutes
    "role": "user",
}

token = jwt.encode(payload, SECRET, algorithm="HS256")
print(token)
# eyJhbGciOi...

# Anyone with the secret can verify and decode
decoded = jwt.decode(token, SECRET, algorithms=["HS256"])
print(decoded)
# {'sub': 'user-42', 'iat': ..., 'exp': ..., 'role': 'user'}

Two non-negotiable details:

  • algorithms=["HS256"] is a list, and you must pass it. The original pyjwt.decode(token, key) API let the token's own header declare the algorithm — including "alg": "none" — and old libraries would happily verify it. That's the algorithm-confusion vulnerability. Always pin the algorithm explicitly.
  • The secret must be long and random. A short or human-chosen secret is brute-forceable offline. 32+ bytes from secrets.token_urlsafe(32), stored in the environment (see envconfig).

Catch the error types you actually expect:

python
import jwt

try:
    claims = jwt.decode(token, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
    # token was valid but its exp has passed
    raise Unauthorised("token expired")
except jwt.InvalidTokenError:
    # signature failed, malformed, wrong audience, etc.
    raise Unauthorised("invalid token")
+ setup added so this can run · defines token, SECRET, Unauthorised
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

token = _AutoMock('token')
SECRET = _AutoMock('SECRET')
def Unauthorised(*_a, **_kw):
    print('-> Unauthorised() called')
    return _AutoMock('Unauthorised()')

InvalidTokenError is the base class for everything else (InvalidSignatureError, DecodeError, InvalidAudienceError, …). One specific ExpiredSignatureError + a fallback to InvalidTokenError covers the realistic flow.


6. Short Expiry + Refresh Tokens

The longer a token lives, the longer a stolen one is useful. The longer a token lives, the harder revocation is. So: make access tokens short — 15 minutes is typical — and pair them with a long-lived refresh token that exchanges for a new access token.

text
POST /login            → access_token (15 min) + refresh_token (30 days)
GET  /api/things       → Authorization: Bearer <access_token>
                         (access valid → 200 OK)

[15 minutes later]

GET  /api/things       → 401 (access expired)
POST /auth/refresh     → access_token (new, 15 min) + refresh_token (rotated)
GET  /api/things       → 200 OK

The refresh token:

  • Is stored more carefully than the access token. Server-side, ideally — track a row per token, link it to a user and a device, mark used/revoked.
  • Should rotate on every use. Single-use semantics. If the same refresh token is presented twice, treat it as theft — revoke the chain, force re-auth.
  • Has a long but finite lifetime (30-90 days). Forever-tokens are a liability.

The access token stays a JWT. The refresh token can be a JWT, but most production systems use an opaque random string backed by a server-side store — because revocation matters for the long-lived one, and stateless JWT-style revocation is the hard problem.


7. Where Does the Client Store the Token?

The classic dilemma:

StorageReads itAttack vector
localStorageJS codeXSS — any script on the page can exfiltrate it.
HttpOnly cookieBrowser only, on requestsCSRF — cross-site requests carry it.
Memory (JS variable)JS code, lost on refreshXSS, but lifetime is tiny.

In 2026, the consensus pattern for SPAs is the worst of all three, used together:

  • Access token (short-lived, 15 min): in JS memory only. Lost on refresh; refresh flow restores it.
  • Refresh token: HttpOnly + Secure + SameSite=Strict cookie. Browser sends it only on /auth/refresh (path-scoped). XSS can't read it; SameSite=Strict blocks CSRF.
  • CSRF token for any state-changing request: standard double-submit-cookie or synchroniser-token pattern (see security-checklist §CSRF).

The "just localStorage" approach is fast to ship and fast to regret — one XSS bug exfiltrates every user's session to the attacker's pastebin in seconds.


8. Validation — What You Must Check

jwt.decode checks the signature and exp by default. The rest you ask for explicitly:

python
claims = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    audience="https://api.myapp.com",                # validates `aud` claim
    issuer="https://auth.myapp.com",                 # validates `iss` claim
    leeway=10,                                       # seconds of clock skew tolerance
    options={"require": ["exp", "iat", "sub"]},      # reject tokens missing these
)
+ setup added so this can run · defines token, SECRET, jwt
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

token = _AutoMock('token')
SECRET = _AutoMock('SECRET')
jwt = _AutoMock('jwt')

The full validation checklist:

  • Signature — automatic, given a correct algorithms= list.
  • exp — automatic.
  • nbf — automatic if present.
  • aud — only if you pass audience=. Multi-tenant systems must check this; otherwise a token for service A can be replayed against service B.
  • iss — pass issuer= when you accept tokens from multiple issuers and need to know which.
  • Required claims — options={"require": [...]} rejects tokens missing them, so you don't silently treat a malformed token as a valid one with weird defaults.

9. Revocation — The Hardest Part

A signed JWT is valid until exp. There's no "log out" button that intrinsically invalidates it. Three options, in increasing pain:

1. Short expiry — the cheap defence.
If access tokens live 15 minutes, the window of stolen-token usefulness is ~15 minutes. For most apps that's enough.

2. Denylist — the brute-force option.
On logout, store jti (or a hash of the token) in Redis with a TTL of the remaining exp - now. Every validating service checks the denylist. Cost: an extra Redis read per request, and you've reintroduced the stateful component JWT was supposed to remove.

3. Rotate the signing key — the nuclear option.
Change SECRET, invalidate every outstanding token everywhere. Use when you suspect the key itself is compromised. Catastrophic to legitimate users.

The pragmatic stack for most apps:

  • 15-min access tokens (no denylist).
  • Server-side refresh tokens (revocable in a DB row).
  • A password_changed_at timestamp on the user row; reject any access token whose iat predates it.

That last trick gives you "log this user out everywhere on password change" without a per-token denylist.


10. A Full FastAPI JWT Auth Flow

Putting it together — login, protected route, all the right validation:

python
import os
import time
from datetime import datetime, timedelta, timezone

import jwt
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

SECRET = os.environ["JWT_SECRET"]                   # 32+ bytes, env var
ALGO = "HS256"
ACCESS_TTL = timedelta(minutes=15)

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login")


def issue_access_token(user_id: str) -> str:
    now = datetime.now(timezone.utc)
    return jwt.encode(
        {
            "sub": user_id,
            "iat": int(now.timestamp()),
            "exp": int((now + ACCESS_TTL).timestamp()),
            "iss": "https://auth.myapp.com",
        },
        SECRET,
        algorithm=ALGO,
    )


def current_user(token: str = Depends(oauth2_scheme)) -> str:
    """FastAPI dependency that validates a bearer token and returns the user_id."""
    try:
        claims = jwt.decode(
            token,
            SECRET,
            algorithms=[ALGO],
            issuer="https://auth.myapp.com",
            options={"require": ["exp", "iat", "sub"]},
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="invalid token")
    return claims["sub"]


@app.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate(form.username, form.password)   # uses PasswordService from prior lesson
    if user is None:
        raise HTTPException(status_code=401, detail="invalid credentials")
    return {"access_token": issue_access_token(user.id), "token_type": "bearer"}


@app.get("/me")
def whoami(user_id: str = Depends(current_user)):
    return {"user_id": user_id}
+ setup added so this can run · defines authenticate
import os  # noqa: F401
os.environ.setdefault("JWT_SECRET", "example-jwt-secret")

# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def authenticate(*_a, **_kw):
    print('-> authenticate() called')
    return _AutoMock('authenticate()')

The shape: OAuth2PasswordBearer reads Authorization: Bearer ..., the current_user dependency decodes-and-validates, every route that needs auth declares Depends(current_user) and gets the user ID for free.


11. Common Mistakes

1. algorithms=None or algorithms=[]. Algorithm-confusion CVE territory. Some old PyJWT examples literally accept {"alg": "none"} because they didn't pin. Always pass the explicit list of algorithms you accept.

2. Treating the payload as encrypted. It's base64, not AES. Anyone with the token can decode it. Don't put passwords, full names if you can avoid it, or anything you wouldn't want on a postcard. Use JWE (JSON Web Encryption) if you need encryption — but you almost never do; you need fewer claims.

3. Long-lived access tokens with no refresh. "We use JWT, exp=30 days" means a stolen token is a 30-day breach. Short access + refresh — or accept the trade-off explicitly.

4. Storing tokens in localStorage. XSS-vulnerable. Use HttpOnly cookies for the refresh token; keep the access token in memory only.

5. Forgetting exp. A JWT without exp is forever-valid. Always set one.

6. Verifying with the wrong key type. HS256 takes a shared secret string; RS256 takes a public key PEM. Mix them up and decode does something disastrous (in pre-2.x PyJWT, treats an RSA public key as an HMAC secret — i.e., anyone who knows the public key can forge tokens). Modern PyJWT refuses, but the lesson stands: pin the algorithm, pass the right key type.

7. Trusting sub without checking it's still valid. A token says sub=42, but user 42 was deleted yesterday. The token is cryptographically valid and semantically stale. Look up the user, or accept that 15-min staleness is OK.

8. Not validating aud/iss in a multi-service system. Service A's token gets replayed against service B. Always set and check audience/issuer.


🎯 Your Turn — issue_token and verify_token

Write two functions:

1. issue_token(user_id: str, secret: str, ttl_minutes: int = 15) -> str — HS256, includes sub, iat, exp.
2. verify_token(token: str, secret: str) -> dict | None — returns the claims dict on success, None on expired or invalid. Pins algorithms=["HS256"]. Requires exp and sub to be present.

Skeleton:

python
import time
import jwt

ALGO = "HS256"

def issue_token(user_id: str, secret: str, ttl_minutes: int = 15) -> str:
    # TODO 1: build payload with sub, iat, exp
    # TODO 2: jwt.encode(...)
    ...

def verify_token(token: str, secret: str) -> dict | None:
    # TODO 1: jwt.decode with algorithms=["HS256"]
    # TODO 2: require exp and sub via options={"require": [...]}
    # TODO 3: return claims on success, None on expired or invalid
    ...
Hint 1 — Timestamps as ints The iat and exp claims should be Unix timestamps in seconds, as integers. int(time.time()) for now; int(time.time() + ttl_minutes * 60) for the expiry. PyJWT does accept datetime objects too, but ints make the intent obvious and avoid timezone surprises.
Hint 2 — Catch InvalidTokenError as the umbrella jwt.ExpiredSignatureError is a subclass of jwt.InvalidTokenError. A single except jwt.InvalidTokenError covers both. The exercise asks for the same return value in either case (None), so one except is enough. In a real app, distinguish them so the UI can say "session expired" vs "invalid session".
Show full solution
python
import time
import jwt

ALGO = "HS256"


def issue_token(user_id: str, secret: str, ttl_minutes: int = 15) -> str:
    """Mint a short-lived HS256 access token for `user_id`."""
    if not user_id:
        raise ValueError("user_id is required")
    if not secret or len(secret) < 32:
        # Catches the most common foot-gun: a 6-char "secret" from an example
        raise ValueError("secret must be at least 32 chars")

    now = int(time.time())
    payload = {
        "sub": user_id,
        "iat": now,
        "exp": now + ttl_minutes * 60,
    }
    return jwt.encode(payload, secret, algorithm=ALGO)


def verify_token(token: str, secret: str) -> dict | None:
    """Validate a token. Returns claims dict on success, None on any failure."""
    try:
        return jwt.decode(
            token,
            secret,
            algorithms=[ALGO],                       # pin — never accept the header's word
            options={"require": ["exp", "sub", "iat"]},
        )
    except jwt.InvalidTokenError:
        # Covers ExpiredSignatureError, InvalidSignatureError, DecodeError,
        # MissingRequiredClaimError, and friends.
        return None


# Demo
SECRET = "this-is-a-long-random-secret-from-environment"

token = issue_token("user-42", SECRET, ttl_minutes=15)
print("token:", token[:30] + "...")

claims = verify_token(token, SECRET)
print("claims:", claims)
# {'sub': 'user-42', 'iat': 1715684400, 'exp': 1715685300}

# Tampered signature → None
tampered = token[:-4] + "AAAA"
print("tampered:", verify_token(tampered, SECRET))     # None

# Wrong secret → None
print("wrong secret:", verify_token(token, "different-secret-also-32-chars-long"))   # None

# Expired → None
expired = issue_token("user-42", SECRET, ttl_minutes=-1)   # already expired
print("expired:", verify_token(expired, SECRET))           # None

What you built:

  • Pinned algorithm — algorithms=["HS256"]. The single most important line. Without it, algorithms=None and accidentally-accepting "alg": "none" is a real vulnerability that has shipped in production.
  • Required claims declared up front — options={"require": [...]}. A token missing exp would otherwise live forever; this rejects it.
  • One-line error funnel — except jwt.InvalidTokenError is the base class. Every realistic failure (bad signature, expired, malformed, missing claims) flows through it.
  • Secret length guard — fails fast at issuance if the configured secret is too short. Helps in dev where someone might pass "secret" and miss that it's brute-forceable.

What's intentionally missing — and would be the next steps in production:

  • aud and iss claims, with audience=/issuer= on decode, when you have multiple services.
  • A separate refresh token and a /refresh endpoint. The access token's job is to be short-lived; the refresh token's job is to outlive it and be revocable server-side.
  • A jti claim + denylist for the rare case you need immediate revocation.
  • An iat-vs-password_changed_at check in the validation pipeline.

But the two-function skeleton is the core of it — every JWT auth system is some hardening of these primitives.


What You Learned

  • A JWT is header.payload.signature — base64-encoded JSON parts plus a signature. Signed, not encrypted. Treat the payload as public.
  • Pin the algorithm. Always algorithms=["HS256"] (or whichever). Letting the token's own header pick the algorithm is the algorithm-confusion CVE.
  • HS256 for monoliths and tightly-coupled services; RS256 for federated multi-issuer setups.
  • Standard claims — sub, iss, aud, exp, iat, nbf, jti. exp is mandatory. Add aud and iss for multi-service systems.
  • Short access tokens (15 min) + long refresh tokens (30 days, server-side, rotating). Don't ship 30-day access tokens.
  • Client storage: refresh token in HttpOnly + Secure + SameSite=Strict cookie; access token in JS memory only. Never localStorage for the long-lived one.
  • Validation: signature, exp, aud (if relevant), iss (if relevant), required claims. jwt.InvalidTokenError is the umbrella exception.
  • Revocation is the hard part. Short expiry handles most cases; denylists work but defeat the stateless win; password_changed_at is the cheap "log out everywhere" trick.

Next: OAuth2 with Real Providers — how "Sign in with Google" actually works, the state parameter that prevents CSRF on the callback, and why OAuth is authorisation, not authentication.