Introduction#
JWTs (JSON Web Tokens) and session tokens (opaque tokens backed by server-side storage) both represent authenticated sessions, but they have fundamentally different properties. Choosing the wrong one for your use case creates security problems or operational complexity.
How Each Works#
Session Tokens#
1
2
3
4
5
6
7
1. User authenticates
2. Server creates a session record in storage (Redis, database)
3. Server returns an opaque random token (e.g., UUID)
4. Client sends token on each request
5. Server looks up the session record to validate
Cookie: session_id=a1b2c3d4-e5f6-7890-...
The token itself contains no information — it is a pointer to server-side state.
JWTs#
1
2
3
4
5
6
7
1. User authenticates
2. Server creates a JWT with claims and signs it
3. Client sends JWT on each request
4. Server validates the signature — no database lookup needed
Header.Payload.Signature
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyOjQyIiwiZXhwIjo...
The token is self-contained — the signature proves it was issued by the server.
JWT Structure and Validation#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import jwt
from datetime import datetime, timedelta
SECRET = "your-secret-key"
ALGORITHM = "HS256"
def create_token(user_id: int, roles: list[str]) -> str:
payload = {
"sub": str(user_id),
"roles": roles,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=1),
"jti": str(uuid.uuid4()), # unique token ID for revocation
}
return jwt.encode(payload, SECRET, algorithm=ALGORITHM)
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(
token,
SECRET,
algorithms=[ALGORITHM], # never accept "none"
options={"require": ["exp", "iat", "sub"]},
)
return payload
except jwt.ExpiredSignatureError:
raise AuthError("Token expired")
except jwt.InvalidTokenError as e:
raise AuthError(f"Invalid token: {e}")
The Revocation Problem#
Session tokens: revocation is instant. Delete the session record from Redis, the user is logged out immediately.
JWTs: revocation is hard. The token is valid until expiry. You cannot “unsign” it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# JWT revocation workaround: blocklist
# Store revoked JTIs in Redis with TTL matching token expiry
import redis
r = redis.Redis()
def revoke_token(token: str) -> None:
payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM])
jti = payload["jti"]
exp = payload["exp"]
ttl = exp - int(datetime.utcnow().timestamp())
if ttl > 0:
r.setex(f"revoked_jti:{jti}", ttl, "1")
def verify_token_with_revocation(token: str) -> dict:
payload = verify_token(token)
if r.exists(f"revoked_jti:{payload['jti']}"):
raise AuthError("Token has been revoked")
return payload
A blocklist requires a database lookup on every request — you’ve now added the storage roundtrip that JWTs were supposed to eliminate.
Where to Store Tokens#
1
2
3
4
5
6
7
8
9
10
// BAD: localStorage — vulnerable to XSS
localStorage.setItem('jwt', token)
// BETTER: memory variable — lost on page reload
let token = null
// BEST for web: HttpOnly secure cookie
// Server sets:
// Set-Cookie: session=...; HttpOnly; Secure; SameSite=Strict; Path=/
// Not accessible to JavaScript — XSS cannot steal it
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# FastAPI: set cookie
from fastapi import Response
@app.post("/login")
async def login(response: Response, credentials: LoginRequest):
token = create_token(user_id=42, roles=["user"])
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=True,
samesite="strict",
max_age=3600,
)
return {"message": "logged in"}
Decision Guide#
| Requirement | Session Token | JWT |
|---|---|---|
| Instant revocation | Yes | No (needs blocklist) |
| No server-side state | No | Yes |
| Microservices (cross-service auth) | Hard (shared Redis needed) | Easy (shared public key) |
| Mobile/API clients | Either | Common choice |
| SPA with XSS risk | HttpOnly cookie | HttpOnly cookie |
| Stateless horizontal scaling | No | Yes |
| Short-lived tokens (< 15min) | Either | Works well |
Refresh Token Pattern#
Short-lived JWTs solve the revocation problem acceptably:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Access token: short-lived (15 min), used for API calls
# Refresh token: long-lived (30 days), used only to get new access tokens
def create_token_pair(user_id: int) -> tuple[str, str]:
access_token = jwt.encode({
"sub": str(user_id),
"type": "access",
"exp": datetime.utcnow() + timedelta(minutes=15),
}, SECRET, algorithm=ALGORITHM)
refresh_token = jwt.encode({
"sub": str(user_id),
"type": "refresh",
"jti": str(uuid.uuid4()), # stored in DB for revocation
"exp": datetime.utcnow() + timedelta(days=30),
}, SECRET, algorithm=ALGORITHM)
# Store refresh token JTI in database
store_refresh_token(user_id, payload["jti"])
return access_token, refresh_token
@app.post("/auth/refresh")
async def refresh(refresh_token: str):
payload = verify_token(refresh_token)
if payload.get("type") != "refresh":
raise AuthError("Not a refresh token")
if not is_valid_refresh_jti(payload["jti"]):
raise AuthError("Refresh token revoked or unknown")
# Issue new access token
return {"access_token": create_access_token(int(payload["sub"]))}
This limits the revocation window to 15 minutes for compromised access tokens, while allowing immediate revocation of refresh tokens.
Conclusion#
Sessions are simpler for traditional web apps where you control the client. JWTs are better for cross-service authentication and mobile clients. The key JWT risks are: storing them in localStorage (XSS-vulnerable), long expiry times (revocation window), and accepting the none algorithm. For web clients, always store tokens in HttpOnly cookies regardless of token type.