The JWT vs session debate is older than 2026 but the answer changed less than people think. The right choice depends on your app shape; “stateless tokens” isn’t an automatic win. This post is the working comparison.

Session cookies

@app.post("/login")
async def login(creds):
    user = await authenticate(creds)
    session_id = secrets.token_urlsafe(32)
    await redis.set(f"sess:{session_id}", json.dumps({"user_id": user.id}), ex=86400 * 30)
    return RedirectResponse("/", headers={"set-cookie": f"sid={session_id}; HttpOnly; Secure; SameSite=Lax"})

@app.middleware("http")
async def auth(request, call_next):
    sid = request.cookies.get("sid")
    if sid:
        data = await redis.get(f"sess:{sid}")
        if data:
            request.state.user = json.loads(data)
    return await call_next(request)

Server stores session; cookie holds opaque ID. Standard since the dawn of time.

JWT

@app.post("/login")
async def login(creds):
    user = await authenticate(creds)
    token = jwt.encode({"sub": user.id, "exp": datetime.utcnow() + timedelta(hours=1)}, SECRET, algorithm="HS256")
    return {"access_token": token}

# Verify
def get_user(token: str = Header(alias="Authorization")):
    token = token.removeprefix("Bearer ")
    payload = jwt.decode(token, SECRET, algorithms=["HS256"])
    return payload["sub"]

Self-contained; no server lookup needed.

When sessions win

  • Same-origin web app: simplest path.
  • Revocation matters: ban a user; delete session; done.
  • Refresh token complexity unwanted.
  • Cookie can be HttpOnly (XSS-safer than localStorage).

For 80% of webapps: sessions.

When JWT wins

  • Cross-domain API: cookies don’t cross domains cleanly.
  • Mobile app: cookies awkward; bearer tokens natural.
  • Microservices: each service verifies independently without shared session store.
  • Scale: stateless verification scales horizontally.
  • Standard: OAuth / OIDC use JWTs.

For service-to-service or mobile-first APIs: JWT.

Hybrid (most common in real apps)

First-party web: session cookie.
Mobile / API: JWT (refresh + access).
Service-to-service: JWT signed with internal key.

Pragmatic. Each surface gets the right tool.

JWT pitfalls

1. Storage in localStorage

XSS reads it; full account compromise. HTTP-only cookie better.

// BAD
localStorage.setItem("token", token);

// BETTER
// Set as HttpOnly cookie from server

2. No revocation

User leaves company; their JWT is valid until expiry. Mitigations:

  • Short TTL (5-15 min).
  • Refresh tokens (longer, revocable).
  • Token version in DB; check on every request (now stateful — defeats main JWT advantage).

3. None algorithm

Old JWT bug: alg: none accepted by some libraries. Always pin algorithm:

jwt.decode(token, SECRET, algorithms=["HS256"])  # explicit

4. Public claims

JWTs are signed, not encrypted. Anyone with the token can read it. Don’t put sensitive data in claims.

5. Algorithm confusion

Server expects HS256; attacker sends RS256-signed with public key as HMAC secret → accepted. Pin algorithm.

Refresh + access pattern

Access token: 15 min, JWT.
Refresh token: 30 days, opaque, server-stored.
@app.post("/refresh")
async def refresh(refresh_token: str):
    record = await db.fetchrow("SELECT * FROM refresh_tokens WHERE token = $1 AND revoked = false", refresh_token)
    if not record or record.expires_at < now(): raise InvalidToken
    
    new_access = jwt.encode({"sub": record.user_id, "exp": now() + 15 * 60}, SECRET, "HS256")
    return {"access_token": new_access}

Access tokens are short-lived JWTs (no revocation needed). Refresh tokens are long-lived, server-stored, revocable.

For revocation: revoke refresh token; access token expires within 15 min.

CSRF protection

Sessions in cookies are CSRF-vulnerable; protect with:

  • SameSite=Lax (default in browsers): blocks cross-site form posts.
  • CSRF token: validated on mutation.

JWTs in Authorization header aren’t CSRF-vulnerable (cross-site requests can’t add custom headers).

XSS protection

If JWT is in localStorage: XSS = compromise. HttpOnly cookies are safer.

For cookies (session OR JWT-in-cookie):

  • HttpOnly.
  • Secure.
  • SameSite=Lax or Strict.

OAuth / OIDC

If you’re integrating Google / GitHub / etc. login: you receive JWTs (ID tokens) from those providers. You still choose what session shape your own app uses.

Common: receive OIDC token → create your own session (for first-party UI) and/or your own JWT (for API).

See Authentication 2026 .

Passkeys

WebAuthn / passkeys complement either choice. Authentication flow uses passkey; resulting session can be cookie or JWT.

Passkeys are the auth method; sessions/JWTs are the post-auth state. Don’t conflate.

Service-to-service

Service A → Service B
Authorization: Bearer <signed JWT with internal key>

JWT signed by A’s private key; B verifies with A’s public key. Standard for internal APIs.

For service mesh (Istio): mTLS handles this; JWT optional.

Common mistakes

1. JWT for first-party web

Adds complexity. Sessions are simpler.

2. Long JWT TTL with no refresh

Stolen JWT works for hours. Short TTL + refresh.

3. JWT in localStorage + XSS

Game over for compromised users. HttpOnly cookies.

4. No algorithm pin

Algorithm confusion attack. Always pin.

5. Big payloads

JWT in cookie/header on every request. 4KB JWT = 4KB per request overhead. Keep claims minimal.

What I’d ship today

For first-party web:

  • Session cookie (HttpOnly, SameSite=Lax).
  • Redis-backed session store.
  • Passkey / WebAuthn as auth method.

For mobile / cross-domain API:

  • Refresh + access JWT pattern.
  • Refresh in HttpOnly cookie (or secure storage).
  • Access in memory (or short-lived storage).

For service-to-service:

  • JWT signed with service key, or mTLS via mesh.

Mix freely.

Read this next

If you want my session + JWT hybrid auth reference, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .