JWT vs Sessions for APIs

BackendSecurityAPIArchitecture
Share on LinkedIn

The JWT vs sessions debate generates more heat than almost any backend architecture decision, and most of it misses the point. Neither is universally better — they're optimized for different constraints. JWTs shine when you need stateless verification across services; sessions shine when you need instant revocation and simpler mental models. I've built auth for mobile apps (JWT + refresh), internal admin tools (sessions + cookies), and microservice platforms (JWT with short TTL). The wrong choice isn't picking JWT or sessions — it's picking one without understanding what you're trading.

How each works

Session-based:

Login → Server creates session ID → Stores in Redis/DB
      → Returns session cookie to client
Request → Client sends cookie → Server looks up session → Authorizes
Logout → Server deletes session → Immediate revocation

JWT-based:

Login → Server signs JWT with claims → Returns token to client
Request → Client sends Authorization: Bearer <jwt> → Server verifies signature → Authorizes
Logout → Client deletes token → Token still valid until expiry

Comparison

Factor Sessions JWT
Revocation Instant (delete session) Delayed (wait for expiry)
Scalability Needs shared session store Stateless verification
Server memory Session store required None (stateless)
Token size Small (session ID) Larger (claims embedded)
Cross-service Requires session lookup Self-contained verification
Mobile apps Awkward (cookies) Natural (Bearer header)
XSS risk HttpOnly cookies mitigate localStorage is vulnerable

JWT pattern for mobile/SPA

Short-lived access + refresh rotation:

# Login
access_token = create_jwt(user_id, expiry=timedelta(minutes=15))
refresh_token = generate_opaque_token()
store_refresh_token(user_id, refresh_token, expiry=timedelta(days=30))
return {"access_token": access_token, "refresh_token": refresh_token}

# Refresh
def refresh(refresh_token):
    stored = lookup_refresh_token(refresh_token)
    if not stored or stored.expired:
        raise Unauthorized()
    delete_refresh_token(refresh_token)  # rotate — old token invalid
    new_refresh = generate_opaque_token()
    store_refresh_token(stored.user_id, new_refresh)
    new_access = create_jwt(stored.user_id, expiry=timedelta(minutes=15))
    return {"access_token": new_access, "refresh_token": new_refresh}

Access token: 15 minutes. Refresh token: 30 days, rotated on use, stored server-side. Stolen refresh token is detectable (rotation fails on reuse).

For mobile, see OAuth PKCE patterns for the authorization flow.

Session pattern for web apps

@app.post("/login")
def login(credentials):
    user = authenticate(credentials)
    session_id = secrets.token_urlsafe(32)
    redis.setex(f"session:{session_id}", 86400, json.dumps({"user_id": user.id}))
    response.set_cookie("session", session_id, httponly=True, secure=True, samesite="Strict")
    return response

@app.get("/api/data")
def get_data(request):
    session = redis.get(f"session:{request.cookies['session']}")
    if not session: raise Unauthorized()
    user_id = json.loads(session)["user_id"]
    return fetch_data(user_id)

HttpOnly + Secure + SameSite cookies prevent XSS and CSRF token theft. Instant revocation by deleting the Redis key.

When JWT goes wrong

Long-lived JWTs without refresh. A 30-day JWT can't be revoked. User changes password, admin disables account — token still works for weeks.

Sensitive data in payload. JWT payloads are base64, not encrypted. Never put PII, permissions that change frequently, or secrets in claims.

No algorithm verification. Always specify allowed algorithms in verification. The alg: none attack is still relevant.

jwt.decode(token, key, algorithms=["RS256"])  # explicit algorithm

Token in localStorage. XSS steals it. For SPAs, use HttpOnly cookies for refresh tokens and in-memory access tokens.

When sessions go wrong

Session store as SPOF. If Redis goes down, nobody can authenticate. Replicate and monitor the session store.

Session fixation. Regenerate session ID on login/privilege change.

Not scaling session store. Sticky sessions without shared store break with load balancers. Always use a centralized store (Redis, Memcached).

Hybrid approach

Many production systems combine both:

This gives you stateless API verification with revocable refresh and the ability to force-logout users.

Rotate JWT signing keys with overlap period — instant key swap invalidates all outstanding tokens and logs out every user.

Token validation checklist

Production JWT verification must include:

payload = jwt.decode(
    token,
    key=get_signing_key(kid=header["kid"]),
    algorithms=["RS256"],  # never accept "none"
    audience="https://api.example.com",
    issuer="https://auth.example.com",
    options={"require": ["exp", "iat", "sub"]},
)

M2M vs user auth

Context Recommendation
SPA user sessions Hybrid JWT + refresh cookie
Mobile apps PKCE + refresh token in secure storage
Service-to-service Client credentials or mTLS
Public API API keys with scoped permissions

Don't use session cookies for mobile APIs — WebView cookie handling is inconsistent across platforms.

Pair with OAuth2 client credentials M2M for backend service authentication patterns.

Common production mistakes

Teams get authentication jwt vs sessions wrong in predictable ways:

API design for authentication jwt vs sessions frustrates clients when pagination cursors expire silently, error bodies lack stable machine-readable codes, and rate limits return 429 without Retry-After headers.

Debugging and triage workflow

When authentication jwt vs sessions misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

When should I use JWT instead of sessions for API authentication?

Use JWT when you need stateless authentication across multiple services (microservices, serverless) where checking a central session store on every request is impractical. Use sessions when you have a monolith or small service count, need instant revocation, or want simpler security semantics.

What are the main security risks of JWT?

JWTs can't be revoked until they expire — a stolen token is valid until expiry. Mitigate with short-lived access tokens (15 minutes), refresh token rotation, and token blocklists for critical revocations. Never store sensitive data in JWT payloads — they're base64-encoded, not encrypted.

How do refresh tokens work with JWT?

Issue a short-lived access token (JWT, 15 min) and a long-lived refresh token (opaque, stored server-side or in HttpOnly cookie). When the access token expires, the client exchanges the refresh token for a new access token. Rotate refresh tokens on each use to detect theft.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →