C.W.K.
Stream
Lesson 03 of 06 · published

JWT & Refresh — The Format, the Claims, and the Refresh Dance

~12 min · auth-security, jwt, claims, refresh

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"JWT is base64 with a signature. That's it. The mystique around it disappears when you can decode a JWT by hand on a napkin — and the security model becomes clear: anyone can read the claims, only the signing key holder can issue valid ones."

The Three-Part Format

A JWT is three base64url-encoded parts joined by dots: HEADER.PAYLOAD.SIGNATURE. Each part is a JSON object (header and payload) or a binary signature, encoded so the whole thing fits in an HTTP header.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1XzQyIiwiZXhwIjoxNzYwMDAwMDAwfQ.signature_bytes

Header: {"alg":"HS256","typ":"JWT"} — algorithm + type. Payload: {"sub":"u_42","exp":1760000000} — the claims (subject + expiry, here). Signature: HMAC-SHA256 or RSA signature of base64(header) + "." + base64(payload) using the issuer's secret/private key.

Anyone can copy a JWT, base64-decode its first two parts, and read the claims. The signature proves the token was issued by someone with the signing key and hasn't been altered. JWT is signed, not encrypted. Putting secrets in the payload is a top-five mistake.

Standard Claims (and Why You Use Them)

RFC 7519 reserves a handful of three-letter claim names:

  • iss (issuer) — who minted the token. Lets clients trust the right signing key.
  • sub (subject) — who/what the token represents (usually a user ID).
  • aud (audience) — who the token is for. Lets one issuer serve many services without cross-contamination.
  • exp (expiry) — Unix timestamp after which the token is invalid. Always include.
  • nbf (not before) — Unix timestamp before which the token is invalid. Rare; useful for scheduled access.
  • iat (issued at) — Unix timestamp when minted. Useful for audit + grace windows.
  • jti (JWT ID) — unique ID. Required if you want to support revocation (you blacklist by jti).

Add your own claims freely (role, scope, org_id). Keep them small — every JWT is sent on every request.

The Three Critical Security Gotchas

1. alg=none. Early JWT libraries accepted {"alg":"none"} tokens — no signature required. An attacker could craft any token and your library would accept it. Always reject alg: none at the verifier; modern libraries do this by default, but verify yours does.

2. Algorithm confusion. An attacker takes a token signed with RS256 (asymmetric) and tells the verifier it's HS256 (symmetric). If your code looks up the verification key by algorithm, the verifier uses the PUBLIC key as the HMAC key — which the attacker already has — and accepts the forged token. Always pin the expected algorithm.

3. Putting secrets in the payload. Payload is base64-encoded, not encrypted. Anyone with the token can read it. Don't put passwords, internal user details, or anything you don't want a leaked token to expose.

HS256 vs RS256 — Which Algorithm

  • HS256 (HMAC-SHA256, symmetric): one shared secret. Issuer and verifier are the same party (or fully trust each other). Simpler; fast; good for single-service backends.
  • RS256 (RSA, asymmetric): private key signs; public key verifies. Issuer (auth server) holds the private key; verifiers (multiple resource servers) hold only the public key. Use when issuer ≠ verifier or when distributing verification widely.

Most identity providers (Auth0, Okta, Google, Apple) use RS256. Single-service apps often use HS256 for simplicity.

JWTs make verification stateless; that's the win and the trade. No DB lookup per request — the signature proves the token is valid; the exp claim proves it's still fresh. The trade: you can't revoke a JWT before its exp without adding stateful revocation infrastructure (jti blacklist in Redis). Short exp + refresh tokens is the practical compromise.

The Refresh Dance

Short access tokens (~15 minutes) keep leak windows tight. But forcing users to log in every 15 minutes is hostile. The compromise: pair the access token with a longer-lived refresh token (~30 days):

  1. Login issues both: access (15m) + refresh (30d).
  2. Client uses access on every API call. When it nears expiry, client posts the refresh token to /auth/refresh and receives a new access (and optionally a new refresh, rotating it).
  3. Refresh tokens are stored more carefully than access tokens — usually HttpOnly cookies, never in localStorage, never sent on regular API calls.
  4. Logout revokes the refresh token server-side; the access token expires naturally within 15 minutes.

The refresh rotation pattern: each successful /refresh also issues a NEW refresh token and invalidates the old one. This bounds the damage if a refresh token leaks — the legitimate user's next refresh detects the duplicate use and invalidates the entire session.

cwkPippa's JWT Reality

cwkPippa doesn't currently use JWTs — its bearer tokens are opaque random strings stored in SQLite with expiry, validated by DB lookup. This is simpler than JWT for a single-backend service and makes revocation trivial (just delete the row). If we ever split the API into multiple services or hand verification off to a CDN edge, JWT's stateless verification becomes attractive. For one server, stateful tokens win on simplicity. The right tool depends on the topology, not on what's fashionable.

Code

Decoding a JWT by hand — header and payload are not secret·python
# Decode a JWT by hand — no library needed
import base64, json

token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1XzQyIiwiZXhwIjoxNzYwMDAwMDAwfQ.signature'
header_b64, payload_b64, sig_b64 = token.split('.')

# Add padding because base64url omits it
def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))

header  = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
print(header)   # {'alg': 'HS256', 'typ': 'JWT'}
print(payload)  # {'sub': 'u_42', 'exp': 1760000000}
# The signature is the only protected part — anyone can read the rest.
PyJWT — issue + verify with explicit algorithm pinning·python
# Issuance + verification with PyJWT (production-grade)
import jwt, time

SECRET = 'super-long-random-secret-from-env'

# Issue a token
def issue_token(user_id: str, role: str) -> str:
    payload = {
        'iss': 'cwkpippa',
        'sub': user_id,
        'aud': 'cwkpippa-api',
        'iat': int(time.time()),
        'exp': int(time.time()) + 900,  # 15 min
        'role': role,
    }
    return jwt.encode(payload, SECRET, algorithm='HS256')

# Verify a token (algorithm-pinned)
def verify_token(token: str) -> dict:
    return jwt.decode(
        token,
        SECRET,
        algorithms=['HS256'],          # PIN — never accept others
        audience='cwkpippa-api',
        issuer='cwkpippa',
        options={'require': ['exp', 'sub', 'iat']},
    )

# Use
token = issue_token('u_42', 'daughter')
claims = verify_token(token)
print(claims)  # {'iss': 'cwkpippa', 'sub': 'u_42', 'role': 'daughter', ...}
Refresh with rotation + reuse detection — the production-grade pattern·python
# Refresh flow with rotation (the canonical pattern)
import jwt, secrets, time
from fastapi import FastAPI, HTTPException, Cookie, Response

app = FastAPI()
ACCESS_SECRET  = 'access-secret-from-env'
REFRESH_STORE: dict[str, dict] = {}  # refresh_token -> {user_id, family_id, used: bool}

def issue_access(user_id: str) -> str:
    return jwt.encode({'sub': user_id, 'exp': int(time.time()) + 900}, ACCESS_SECRET, algorithm='HS256')

def issue_refresh(user_id: str, family_id: str | None = None) -> str:
    rt = secrets.token_urlsafe(32)
    REFRESH_STORE[rt] = {
        'user_id': user_id,
        'family_id': family_id or secrets.token_hex(8),
        'used': False,
    }
    return rt

@app.post('/auth/refresh')
async def refresh(response: Response, refresh_token: str | None = Cookie(None)):
    record = REFRESH_STORE.get(refresh_token or '')
    if not record or record['used']:
        # Reuse detection — revoke the entire family
        if record and record['used']:
            for rt, r in list(REFRESH_STORE.items()):
                if r['family_id'] == record['family_id']:
                    del REFRESH_STORE[rt]
        raise HTTPException(401, detail='refresh token invalid or reused')

    # Mark used, issue new pair
    record['used'] = True
    new_refresh = issue_refresh(record['user_id'], record['family_id'])
    response.set_cookie('refresh_token', new_refresh, httponly=True, secure=True, samesite='strict')
    return {'access_token': issue_access(record['user_id']), 'expires_in': 900}

External links

Exercise

Implement a JWT auth flow in FastAPI from scratch: /login returns access (HS256, 15-min) + refresh (opaque, 30-day) tokens; /auth/refresh rotates the refresh token and issues a new access; /me requires a valid access. Add reuse detection on refresh (presenting the same refresh twice revokes the family). Then deliberately try three attacks: (1) modify the access token payload without re-signing — should 401, (2) submit an alg=none token — should 401, (3) submit a refresh token after it's been used to refresh once — should 401 AND invalidate any other refresh tokens in the family.
Hint
PyJWT for issuance/verification: jwt.encode({...}, SECRET, algorithm='HS256') and jwt.decode(token, SECRET, algorithms=['HS256']). Reuse detection needs a family_id stored with each refresh token; on rotation, mark used=True; on a request with used=True, find all tokens with the same family_id and delete them. The three attacks should all fail with 401 — that's the integration test that proves your implementation is sound.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.