Cookies, server-side sessions and JWTs are different things

Debates about session handling often stall because three different things get treated as alternatives to each other. A cookie is a transport. It is an HTTP header the browser stores and sends back automatically to matching origins. A cookie says nothing about what it carries. A server-side session is state held on the server, such as in memory, a database or a cache. The server looks that state up using an opaque, random session ID, and the cookie carries only that ID. Cookie sessions and JWTs are client-held state. The user's data travels with every request. A signature makes it tamper-evident, and encryption can also keep it confidential.

Approach Where state lives What the client holds Server lookup per request
Server-side session Server store Opaque random ID Yes
Signed/encrypted cookie session Client Serialized data plus signature No (verify only)
JWT Client Signed claims (optionally encrypted) No (verify only)

A JWT can be sent in a cookie just as easily as in an Authorization header. That makes "cookies vs JWT" a false dichotomy. The real choice is whether the authoritative session state lives on the server or with the client.

This article is for backend and platform engineers running a single application that serves browsers. That application handles login and also serves every request that needs identity. In that setup, client-held tokens bring little benefit. No other service needs to verify identity independently, and the session store is close to the code that reads it. So this article takes a clear position: server-side sessions keyed by an opaque cookie should be your default. The rest of the article explains why and covers the cases that justify something else.

How server-side sessions work end to end

At login, the server generates a session ID using a cryptographically secure random number generator (CSPRNG), with at least 128 bits of entropy, encoded as hex or base64url. The ID means nothing on its own. It is only a lookup key, so its security depends entirely on being unguessable. That rules out general-purpose PRNGs and anything derived from user IDs or timestamps.

The server writes a record under that ID containing the user ID, creation time, last-activity time and any small state you need. It then returns the ID in a cookie.

Where the store sits

The request path is client → load balancer → any app instance → session store. The store is a database table or a shared cache such as Redis, and it sits behind the instances where all of them can reach it. Because any instance can resolve any session, the load balancer can route freely, and you can add, drain or replace instances without logging anyone out.

On every request, the instance reads the cookie, fetches the record and rejects the request if the record is missing or expired. Enforce two timeouts on the server:

  • Idle timeout: time since last activity.
  • Absolute timeout: time since creation, regardless of activity.

Cookie expiry doesn't count as enforcement, because the client controls it. Updating last-activity on every request means a write per request, so throttle it, for example to once per minute.

Sticky sessions

You can also keep sessions in process memory and use sticky routing to pin each user to one instance. This is legitimate and saves a network hop. It is also fragile. Every deploy or crash wipes that instance's sessions, and long-lived pinned users can leave one instance overloaded while others sit idle.

 +----------+   Cookie: sid=<128-bit CSPRNG ID, hex or base64url>
 |  Client  |
 +----------+
      |
      v
 +-----------------------------------------------+
 |  Load balancer (routes freely, no stickiness) |
 +-----------------------------------------------+
      |               |               |
      v               v               v
 +----------+    +----------+    +----------+
 | App      |    | App      |    | App      |
 | inst. A  |    | inst. B  |    | inst. C  |
 +----------+    +----------+    +----------+
      |               |               |
      +---------------+---------------+
                      |
                      v
 +-----------------------------------------------+
 | Shared session store (DB table or Redis)      |
 | sid -> user_id, created_at, last_active, ...  |
 +-----------------------------------------------+

 Per request, on any instance:
 read cookie -> fetch record -> missing or expired? -> reject
 idle: now - last_active    absolute: now - created_at
 update last_active at most once per minute (throttled write)

Any app instance can resolve any session because the load balancer sits in front of the instances and the shared session store sits behind them, with idle and absolute timeouts checked on the server for every request.

Whichever store you choose, the session ID itself needs the most care. Generate it from a cryptographically secure random source with at least 128 bits of entropy. Never derive it from a timestamp, user ID or counter, because the ID is the only credential an attacker needs. Write it to the store with a TTL so abandoned sessions expire without a cleanup job. Track an absolute lifetime alongside the idle timeout so that an active session cannot be extended forever.

Send the ID as Set-Cookie: __Host-sid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax. The __Host- prefix makes the browser reject the cookie unless it is Secure, has Path=/ and has no Domain attribute. That keeps the cookie host-only, so a compromised sibling subdomain cannot set or overwrite it.

Regenerate the ID on login and on every privilege change, such as elevation, role grant or password change. Copy the session data to a new ID, then delete the old key. Hand-rolled code often skips the delete or skips regeneration entirely. Either gap leaves session fixation open: an attacker who planted an ID before login inherits the authenticated session.

SameSite reduces CSRF risk but does not eliminate it. Same-site subdomains and top-level GET navigations under Lax still get through, so state-changing requests still need CSRF tokens.

HttpOnly will not stop injected script from making requests within the victim's session. It does stop the script from reading the ID and reusing it elsewhere, which a token in localStorage cannot prevent.

Parallel requests that read, modify and write the whole session cause lost updates, because the last writer silently wins. Use per-field updates or versioned compare-and-set writes to avoid this.

import secrets, time
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
IDLE_TTL = 30 * 60        # idle timeout, enforced by the store's key TTL
ABSOLUTE_TTL = 8 * 3600   # hard cap so activity cannot extend a session forever

def create_session(data: dict) -> str:
    sid = secrets.token_urlsafe(32)  # 256 bits from the OS CSPRNG, never time/user/counter
    key = f"sess:{sid}"
    # Fresh CSRF token per session; still required for state-changing requests despite SameSite
    r.hset(key, mapping={**data, "created_at": int(time.time()), "csrf": secrets.token_urlsafe(32)})
    r.expire(key, IDLE_TTL)          # abandoned sessions expire without a cleanup job
    return sid

def load_session(sid: str):
    key = f"sess:{sid}"
    data = r.hgetall(key)
    if not data or time.time() - int(data["created_at"]) > ABSOLUTE_TTL:
        r.delete(key)
        return None
    r.expire(key, IDLE_TTL)          # slide the idle window only
    return data

def rotate_session(old_sid: str, changes: dict) -> str:
    # Call on login and on every privilege change (elevation, role grant, password change)
    data = r.hgetall(f"sess:{old_sid}")
    data.update(changes)
    new_sid = create_session(data)   # new ID and new CSRF token
    r.delete(f"sess:{old_sid}")      # the step hand-rolled code skips: kill the fixated ID
    return new_sid

def update_field(sid: str, field: str, value: str) -> None:
    # Per-field write: parallel requests don't overwrite each other's changes
    r.hset(f"sess:{sid}", field, value)

def session_cookie(sid: str) -> str:
    # __Host- requires Secure, Path=/ and no Domain, so the cookie stays host-only
    return f"Set-Cookie: __Host-sid={sid}; Path=/; Secure; HttpOnly; SameSite=Lax"

A minimal Redis-backed session layer that generates CSPRNG IDs, stores sessions with idle and absolute lifetimes, rotates the ID and deletes the old key on login or privilege change, uses per-field writes to avoid lost updates, and emits a __Host- prefixed HttpOnly, Secure, SameSite cookie.

Revocation and logout: the deciding factor

Most of the trade-offs so far come down to one question: when you need a user logged out now, can you do it?

With server-side sessions, yes. The session ID in the cookie is only a pointer. Delete the row and the next request finds nothing, so the user is anonymous. "Log out all devices" after a password change is one query: delete every session where user_id matches. No other part of the system has to cooperate.

A stateless token stays valid until it expires, because the server checks only the signature and the claims. To kill it early you need state. One option is a denylist of revoked token IDs. The other is a per-user token version:

  • Store an integer token_version on the user record.
  • Embed the current value in each token at issue time.
  • On every request, load the user's token_version and reject the token if the values differ.
  • To log out everywhere, increment the counter.

This works, but look at the third step. It is a per-request lookup keyed by user, which is a session lookup under another name. Once you pay for that read, the main benefit of JWTs is gone.

Short access-token lifetimes with refresh tokens shrink the exposure window but do not remove the state. Refresh tokens must be stored server-side and rotated on each use. You also need reuse detection: if a refresh token that was already rotated is presented again, it has probably been stolen, so revoke the whole token family.

import datetime
import jwt  # PyJWT

SECRET = "replace-with-a-long-random-secret"  # placeholder, load from config

def issue_token(db, user_id):
    # Embed the user's current token_version so it can be compared later.
    (version,) = db.execute(
        "SELECT token_version FROM users WHERE id = ?", (user_id,)
    ).fetchone()
    exp = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15)
    claims = {"sub": str(user_id), "ver": version, "exp": exp}
    return jwt.encode(claims, SECRET, algorithm="HS256")

def authenticate(db, token):
    # Stateless part: signature and expiry only, no I/O.
    claims = jwt.decode(token, SECRET, algorithms=["HS256"])
    user_id = int(claims["sub"])
    # Revocation part: a database read on every request, keyed by user.
    # Same cost as: SELECT user_id FROM sessions WHERE id = ?
    row = db.execute(
        "SELECT token_version FROM users WHERE id = ?", (user_id,)
    ).fetchone()
    if row is None or row[0] != claims["ver"]:
        raise PermissionError("token revoked")
    return user_id

def logout_everywhere(db, user_id):
    # Bumping the counter invalidates every token issued before now.
    # Server-session equivalent: DELETE FROM sessions WHERE user_id = ?
    db.execute(
        "UPDATE users SET token_version = token_version + 1 WHERE id = ?",
        (user_id,),
    )
    db.commit()

A per-user token-version check with PyJWT, showing that revoking stateless tokens requires a per-request database read equivalent to a server-session lookup.

Signed or encrypted cookie sessions store the whole session in the cookie, so the server stores nothing. The costs move elsewhere:

  • Logout and theft. Logout only overwrites the cookie in one browser. A stolen copy stays valid until it expires, unless you embed a version number that the server checks. That check brings back a server lookup.
  • Readable contents. A signed but unencrypted cookie exposes its contents to anyone who holds it.
  • Size. Browsers cap cookies at a few kilobytes. A session that keeps accumulating data eventually causes failed requests or truncated headers.
  • Stale data and races. Roles cached in the cookie stay stale until it is reissued. A slow, older response can also overwrite a newer cookie.
  • Key rotation. Rotating keys needs several active keys and an overlap period. Without that, every user is logged out at once.

JWTs add their own failure modes:

  • none algorithm. Accepting it means accepting unsigned tokens.
  • Algorithm confusion. If the verifier lets the token choose its algorithm, an attacker can sign a token with HMAC, using your RSA public key as the secret.
  • Missing claim checks. Skipping expiry, audience or issuer validation lets tokens be used after expiry or by the wrong service.
  • Exposed payload. The payload is base64url-encoded, not encrypted, so it must not hold sensitive data.
Approach Server storage Instant revocation Main risk
Server session Yes Yes Store availability
Signed/encrypted cookie No Only with a version check Size, staleness, theft
JWT No Only with a denylist Validation mistakes

JWTs are still justified in a monolith in three cases:

  • third-party API clients
  • mobile apps that cannot use cookies
  • short-lived identity handed to a separate service

In these cases, browser sessions can stay server-side, with tokens issued alongside them.

Impact on cost and engineering time

Illustrative estimate (assumed numbers, not a benchmark). Assume 500 requests per second, one session read per request, a 0.5 ms round trip to an in-memory cache on the same network, and a 50 ms median response time.

  • Added latency per request = session reads per request × cache round trip = 1 × 0.5 ms = 0.5 ms
  • Share of response time = 0.5 ms ÷ 50 ms = 1%
  • Cache load = 500 requests/s × 1 read = 500 reads/s, a light load for a single cache node

Plug in your own figures. Unless your round trip is unusually slow or you read the session several times per request, the database queries and rendering behind that 50 ms will dominate.

The real cost is operational. The session store is now a stateful dependency, so you need to decide three things:

  • Availability: if the store goes down, every user is effectively logged out.
  • Persistence: does a restart wipe all sessions?
  • Eviction policy: a cache configured for LRU eviction will drop sessions under memory pressure without raising an error. Users simply get logged out. Give sessions a dedicated store or a no-eviction policy, and alert on memory use.

Stateless tokens shift that cost into engineering time instead. You have to build:

  • a denylist for revocation
  • refresh-token rotation with reuse detection
  • signing-key rotation
  • validation hardening: pinning algorithms and checking issuer, audience and expiry on every path

Whichever approach you choose, put session handling behind a single interface that exposes operations such as create, load, rotate and revoke. If you later split the monolith or open a public API, you can add token issuance behind that interface without touching call sites. That cheap seam is the reason to wait. It is not a reason to adopt JWTs before you need them.

Decision checklist: choosing a session approach for your monolith

Start from server-side sessions and move away only when a specific requirement forces you to. Server sessions are the only approach that answers "who is logged in right now?" by reading a table rather than by inference, and that answer is what revocation, incident response and audits depend on.

Work through these questions in order:

  • Must logout, password reset or account suspension take effect on the next request? If yes, you need server state. Signed cookies and JWTs then need a denylist, and a denylist is a session store under another name.
  • Do you already run a database or shared cache that every app instance can reach? A monolith almost always does, so a session lookup adds no new infrastructure.
  • Are all clients browsers on your own domain? If so, an opaque cookie covers everything, and tokens add nothing.
  • Do mobile apps, CLIs or third-party integrations call you? These clients handle bearer tokens more naturally than cookie jars. Issue tokens to them, and keep browsers on server sessions. This hybrid is common and sound, provided both paths resolve to the same user and permission model.
  • Do you need an audit trail of active sessions, or the ability to list and terminate them? Only server sessions provide this without extra machinery.
Requirement Server sessions Signed cookies JWTs
Immediate revocation Yes Needs denylist Needs denylist
Lists active sessions for audit Yes No No
Shared store required Yes No No
Suited to non-browser clients Awkward Awkward Yes
Payload size limits None Cookie limit Header size

Choose signed cookies only for small, low-risk state where a stale value causes no harm. Choose JWTs only for API clients that have short expiry and a refresh path.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to monolith session management, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.