JWT tokens and stateless authentication

Sessions are stateful: the server stores data and the client sends an ID. JWTs are the opposite: the client stores encrypted data and the server verifies the signature. No server-side session table needed, scales across servers easily, but the tradeoff is you can't revoke a token until it expires.

Intermediate

6 min read

Sessions vs stateless tokens

Sessions (from lesson 3) are stateful:

  • Server stores session data in memory or database.
  • Client sends a session ID (meaningless without the server).
  • Server looks up the session on every request.

JWTs (JSON Web Tokens) are stateless:

  • Server encodes data + signature and gives it to the client.
  • Client sends the entire token back.
  • Server verifies the signature; if valid, the data is trusted.
  • Server never stores the token.

Tradeoff: sessions need storage but can be revoked instantly; JWTs don't need storage but can't be revoked until expiry.

JWT structure

A JWT is three base64-encoded strings separated by dots:

header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTIzIiwibmFtZSI6ImFsaWNlIn0.abcd1234...

Header: algorithm and type

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload: claims (data about the user)

{
  "user_id": "123",
  "email": "alice@example.com",
  "role": "admin",
  "iat": 1692921600,
  "exp": 1692925200
}

Signature: proof that the server created this token and it hasn't been modified

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  server_secret_key
)

The signature is created with a secret key that only the server knows. If someone modifies the payload, the signature no longer matches, and the server rejects it.

JWT flow

  1. Alice logs in: POST /login with email + password.
  2. Server authenticates: verifies the password is correct.
  3. Server creates JWT:
    payload = {
        'user_id': alice.id,
        'email': alice.email,
        'role': alice.role,
        'iat': now(),           # issued at
        'exp': now() + 24h      # expires at
    }
    token = jwt.encode(payload, secret_key, algorithm='HS256')
    # token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTIzIn0...."
  4. Server sends token: typically in response body or Set-Cookie.
  5. Client stores token: in localStorage, sessionStorage, or a cookie.
  6. Client makes requests: sends the token in the Authorization header:
    GET /profile
    Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTIzIn0....
    
  7. Server verifies JWT:
    token = request.headers['Authorization'].replace('Bearer ', '')
    try:
        payload = jwt.decode(token, secret_key, algorithms=['HS256'])
        user_id = payload['user_id']
        return get_user_profile(user_id)
    except jwt.ExpiredSignatureError:
        return 401  # token expired, ask client to log in again
    except jwt.InvalidSignatureError:
        return 401  # token tampered with

Signing algorithms

HS256 (HMAC with SHA-256): symmetric key

  • Server signs with a secret: jwt.encode(payload, secret_key)
  • Server verifies with the same secret: jwt.decode(token, secret_key)
  • Fast, simple. Both signing and verification need the secret.

RS256 (RSA with SHA-256): asymmetric key pair

  • Server signs with private key: jwt.encode(payload, private_key)
  • Any service verifies with public key: jwt.decode(token, public_key)
  • Slower, but the public key can be shared (e.g., to other microservices or external partners without sharing the private key).

Use HS256 for simple apps. Use RS256 for microservices where multiple services need to verify tokens (each service gets a copy of your public key).

Refresh tokens

JWTs have an expiration (exp claim). When a token expires, the client can't make requests. The user must log in again.

Refresh tokens solve this: issue a short-lived JWT (15 minutes) and a long-lived refresh token (7 days).

Flow:

  1. Alice logs in: server issues access token (15m) + refresh token (7d).
  2. Alice makes requests with the access token.
  3. Access token expires (after 15 minutes).
  4. Client detects 401: tries to refresh. POST /refresh with the refresh token.
  5. Server verifies refresh token: if valid and not expired, issues a new access token (another 15 minutes).
  6. Client retries the original request with the new access token.

Benefit: if an access token is stolen, the attacker can only use it for 15 minutes. The refresh token is longer-lived but only used occasionally (and can be stored more securely, e.g., HttpOnly cookie).

# Login
access_token = jwt.encode({'user_id': alice.id, 'exp': now() + 15m}, secret)
refresh_token = jwt.encode({'user_id': alice.id, 'exp': now() + 7d}, secret)
response.set_cookie('refresh_token', refresh_token, httponly=True, secure=True)
return {'access_token': access_token}  # client stores in localStorage or memory
 
# Refresh (client sends refresh token)
@app.post('/refresh')
def refresh():
    refresh_token = request.cookies.get('refresh_token')
    payload = jwt.decode(refresh_token, secret)
    new_access_token = jwt.encode({'user_id': payload['user_id'], 'exp': now() + 15m}, secret)
    return {'access_token': new_access_token}

Stateless token revocation problem

A JWT is valid until it expires. You can't revoke it early.

Problem: Alice's account is hacked. You want to log her out immediately and invalidate all tokens. You can't — her token is still valid until expiry.

Solutions:

  1. Token blacklist: when you need to revoke, store revoked tokens in a cache (Redis) with a TTL equal to the token expiry. Check the blacklist on every request.
    • Downside: this adds server-side state again (like sessions).
  2. Short expiry: use very short token lifetimes (5-15 minutes) so leaked tokens expire quickly. Refresh tokens handle the long-lived state.
  3. Version counter: add a token_version to the JWT; increment it in the database when the user's password changes or device is revoked. Check the version on every request.

For most apps, refresh tokens + short access token lifetimes (15m) + revocation via token version is the best balance.

Common mistakes

Storing sensitive data in JWT

# WRONG — JWT is readable (base64, not encrypted)
payload = {
    'user_id': alice.id,
    'credit_card': '4532-1111-2222-3333'  # base64 decoded by anyone
}
token = jwt.encode(payload, secret)

CORRECT

payload = {
    'user_id': alice.id,
    'iat': now(),
    'exp': now() + 15m
}
token = jwt.encode(payload, secret)
# store sensitive data (credit card) in the database, keyed by user_id

JWTs are encoded (base64), not encrypted. Anyone can decode the payload and read it. Treat JWT claims as public data.

Forgetting to verify expiry

# WRONG — doesn't check expiry
payload = jwt.decode(token, secret, options={"verify_exp": False})
 
# CORRECT
payload = jwt.decode(token, secret)  # raises ExpiredSignatureError if expired

Not using HTTPS

Without HTTPS, an attacker can intercept the token in the Authorization header. Always use HTTPS; verify the certificate.

Storing JWT in localStorage and relying on it for authentication

// RISKY
localStorage.setItem('access_token', token);  // vulnerable to XSS
// If an XSS attack injects script, localStorage is accessible

SAFER

// Store refresh token in HttpOnly cookie (server-sent)
// Store access token in memory (lost on page reload, but safer from XSS)
let accessToken = token;  // memory only

Page reloads force re-authentication via the refresh token (HttpOnly cookie), which is worth the friction in exchange for XSS protection.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What is the primary advantage of JWTs over sessions?

2. What does the JWT signature prove?

3. Why shouldn't you store credit card numbers in a JWT?

4. What problem do refresh tokens solve?