Sessions vs JWT: How Your App Remembers You're Logged In
After you log in, every request has to prove it's still you. There are two ways to do it: let the server remember you (a session) or carry the proof yourself (a JWT). One is a coat-check ticket, the other a signed wristband — and the difference decides how you revoke access, scale, and store state. Here's the real trade-off, with the code in Python, TypeScript, and Java.
You type your password once. Then you click around for an hour and the app just knows it's you — no re-login on every page. HTTP is stateless; each request arrives as a stranger. So something has to travel with every request to say "yep, still me."
There are two fundamentally different ways to make that happen, and picking between them is one of the first real architecture decisions in any app with a login. Either the server remembers you, or you carry the proof yourself.
That's sessions versus JWT. Here's the actual decision.
The code below is shown in Python (FastAPI), TypeScript (Express), and Java (Spring) — click the tab for your stack.
Sessions: the server remembers you
When you log in, the server creates a record — a session — and stores it somewhere it controls: process memory, Redis, or a database row. It then hands you back one small thing: a session id in a cookie.
import secrets
from fastapi import FastAPI, Response, Request, HTTPException
app = FastAPI()
sessions = {} # in real life: Redis, not a dict
@app.post("/login")
def login(user_id: int, response: Response):
session_id = secrets.token_urlsafe(32)
sessions[session_id] = {"user_id": user_id, "role": "user"}
response.set_cookie("sid", session_id, httponly=True, secure=True, samesite="lax")
return {"ok": True}
@app.get("/me")
def me(request: Request):
session = sessions.get(request.cookies.get("sid")) # the lookup — every request
if not session:
raise HTTPException(401, "Not logged in")
return sessionThe cookie itself is meaningless — it's just a random string. The truth lives on the server, and the server looks it up on every request.
Think of a coat-check ticket. The little numbered stub in your pocket isn't your coat and it isn't worth anything on its own. The venue is holding your coat, and the stub is just how they find it again. Lose the stub and they still have the coat; copy the stub and it's useless without the venue's rack.
Because the server owns the truth, you get real control:
- Instant revoke. Log someone out everywhere, right now? Delete the session record. Done. The next request fails.
- Live changes. Promote a user to admin, ban them, flip a feature — mutate the session and it applies on the very next request.
- A tiny cookie. The browser carries ~32 bytes. All the real data stays server-side, never exposed.
The cost of all that control is one word: state.
The server has to store every active session and look it up on every single request. With one server and Redis, that's trivial. But the moment you scale out to several servers behind a load balancer, they all need to see the same sessions. Two ways out:
- A shared store — everyone reads/writes the same Redis. (The standard answer.)
- Sticky sessions — pin each user to the server that first logged them in. Simpler, but now one server dying logs out everyone it was holding, and your load balancer is doing extra work.
Either way, sessions mean you're running and scaling a place to keep state.
JWT: you carry the proof
A JSON Web Token flips the whole thing around. Instead of storing your session and giving you a pointer to it, the server packs your identity into the token, signs it, and hands the whole thing to you. You send it back on every request, and the server just checks the signature.
import jwt # pyjwt
import time
SECRET = "keep-this-on-the-server-only"
@app.post("/login-jwt")
def login_jwt(user_id: int):
payload = {"sub": user_id, "role": "user", "exp": time.time() + 900} # 15 min
return {"access_token": jwt.encode(payload, SECRET, algorithm="HS256")}
@app.get("/me-jwt")
def me_jwt(request: Request):
token = request.headers["Authorization"].removeprefix("Bearer ")
try:
return jwt.decode(token, SECRET, algorithms=["HS256"]) # verify — no lookup
except jwt.InvalidTokenError:
raise HTTPException(401, "Bad token")No sessions store. No Redis. No database hit. Verifying a JWT is pure math on the request itself: re-compute the signature with the secret, compare. If it matches and it hasn't expired, the claims inside are trusted.
This is a signed festival wristband. The whole identity is on your wrist — "general admission, expires Sunday" — and any door can scan it and let you in without phoning a central desk. That's exactly why JWTs shine for stateless APIs and microservices: every service can verify the token on its own, with nothing but the shared secret (or public key). No shared session store, no chatty lookups between services.
But the wristband model has two catches that bite people in production.
You can't easily un-issue it
A signed wristband stays valid until it expires — that's the whole point, and also the problem. The server isn't keeping a list of "currently valid" tokens to delete from, so there's no clean way to log someone out. If you fire an employee at 2pm and their token expires at 2:15, that token still works for fifteen minutes. Everywhere.
Teams discover this the hard way and end up bolting on... a server-side list of revoked tokens (a "blocklist"). Which is, of course, state — the exact thing JWT was supposed to let them avoid. Keep that irony in mind; it's the heart of the verdict below.
It's readable, not secret
A JWT is signed, not encrypted. The payload is base64 — anyone holding the token can decode it and read every claim:
echo "<the.middle.part>" | base64 -d
# {"sub": 42, "role": "user", "exp": 1750000000}
Signing guarantees nobody tampered with it. It does not hide the contents. So never put anything secret in a JWT — no passwords, no API keys, no PII you wouldn't hand the client. The token is a claim the browser carries around, not a vault.
The verdict
The choice comes down to one question: who owns the truth?
- Reach for sessions when you want instant revoke, server-rendered apps, or just want the simplest thing that obviously works. You own the truth; logging someone out is one line.
- Reach for JWT when you're building stateless APIs, mobile or third-party clients, or microservices where every service needs to verify independently without a shared session store.
And here's the part the "which one wins?" framing misses: most real systems use both. The standard production pattern is a short-lived JWT plus a refresh token:
- A short-lived access token (a JWT, ~15 minutes) that services verify statelessly with no lookup. Its short life is what limits the "can't revoke" damage — a stolen or stale token dies on its own, fast.
- A long-lived refresh token that is tracked server-side (in a database, with state) and is used only to mint new access tokens. Because it lives in your store, you can revoke it instantly — kill the refresh token and the user is locked out within one access-token lifetime.
# Access token: JWT, 15 min, verified statelessly everywhere.
# Refresh token: random string, stored in DB, revocable.
@app.post("/refresh")
def refresh(refresh_token: str):
row = db.get_refresh_token(refresh_token) # stateful lookup — on purpose
if not row or row.revoked:
raise HTTPException(401, "Re-login required")
new_access = jwt.encode(
{"sub": row.user_id, "role": row.role, "exp": time.time() + 900},
SECRET, algorithm="HS256",
)
return {"access_token": new_access}You get JWT's stateless speed on the hot path (every API call), and sessions' real control on the cold path (revoke a refresh token to log someone out for good). The two approaches aren't rivals; they're the two halves of how grown-up auth actually works.
So the next time someone asks "sessions or JWT?", the honest answer is: figure out who needs to own the truth, default to sessions when it's you, reach for JWT when it has to be the client — and reach for both when you want the speed of one and the control of the other.
Liked the coat-check vs wristband framing? It started as a 90-second Sessions vs JWT explainer — watch it, then come back here for the code.