# HTTP Sessions and State Management Explained

> Learn how to manage user state and sessions in stateless HTTP applications using cookies, tokens, and server-side storage.

Source: https://howhttpworks.com/guides/sessions-and-state
Last reviewed: 2026-10-05

> **TL;DR:** HTTP does not maintain a login session for you. A common browser setup stores an opaque session ID in a Secure, HttpOnly cookie and state in a shared server-side store. Enforce expiry and logout on the server; cookie expiry alone is not revocation.

## Introduction

HTTP is stateless at the protocol level, but applications can retain state. An open TCP connection or an HTTP/2 connection is not proof of who the user is. Each protected request must carry credentials the application can validate.

For a server-side session, the cookie is a lookup key. The record behind it can hold an authenticated user ID, a draft, or a cart. Keeping account data out of the cookie does not remove the need to protect the identifier: possession can grant access.

## The State Management Problem

Illustrative headers after login:

```http
Set-Cookie: __Host-session=opaque-session-id; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store
```

On an eligible subsequent request:

```http
Cookie: __Host-session=opaque-session-id
```

The server retrieves the record, checks its expiry and authorization, and handles the request. The cookie's attributes are not repeated in Cookie. Domain, path, Secure, SameSite, Fetch credentials mode, and browser policy determine whether the browser sends it.

A session cookie has no Expires or Max-Age. That is a lifetime category, not a statement about its contents. Browser restore features can restore it after restart, so closing the browser is not a dependable logout mechanism. See [MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie).

## State Management Approaches

### Server-side sessions

Here is current `express-session` and `connect-redis` setup for an Express app. The imports and connection sequence follow [connect-redis's maintained example](https://github.com/tj/connect-redis). Supply SESSION_SECRET and REDIS_URL through your deployment configuration.

```javascript
import express from 'express'
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import { createClient } from 'redis'

const app = express()
const redisClient = createClient({ url: process.env.REDIS_URL })
redisClient.on('error', (error) => console.error('Redis error:', error.message))
await redisClient.connect()
if (!process.env.SESSION_SECRET) throw new Error('SESSION_SECRET is required')

app.use(session({
  name: '__Host-session',
  store: new RedisStore({ client: redisClient, prefix: 'example:sessions:' }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    path: '/',
    secure: true,
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 600000 // Chosen example: 10 minutes, in milliseconds.
  }
}))
```

This is a configuration fragment: add your routes and listener. It requires HTTPS for the Secure cookie. If a trusted reverse proxy terminates TLS, configure Express's `trust proxy` for that actual topology before session middleware. Do not copy a blanket trust setting without knowing which hops can supply forwarding headers. The [session documentation](https://expressjs.com/en/resources/middleware/session/) explains this requirement.

### Signed tokens and cookies

A signed JWT can carry claims the server verifies without a session-record lookup. Signing is not encryption: claims in a JWS JWT are readable. JWT also supports encrypted representations. Cookie sessions can similarly carry signed state instead of an opaque lookup key.

“JWTs cannot be revoked” is incorrect. A denylist or server-side session/version check can reject an otherwise valid token before its expiry, at the cost of consulting state. Check the expected algorithm, issuer, audience, and expiry; do not treat decoded claims as authenticated claims. See [OWASP REST security](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html).

This fragment uses [jsonwebtoken's sign and verify APIs](https://github.com/auth0/node-jsonwebtoken) for a service-issued Bearer token. Supply a high-entropy secret through deployment configuration; the issuer, audience, and ten-minute lifetime are example policy choices:

```javascript
import jwt from 'jsonwebtoken'

const secret = process.env.TOKEN_SECRET
if (!secret) throw new Error('TOKEN_SECRET is required')

function issueToken(userId) {
  return jwt.sign({}, secret, {
    algorithm: 'HS256',
    subject: String(userId),
    issuer: 'https://auth.example.com',
    audience: 'documents-api',
    expiresIn: 600 // Seconds, not milliseconds.
  })
}

function verifyToken(token) {
  return jwt.verify(token, secret, {
    algorithms: ['HS256'],
    issuer: 'https://auth.example.com',
    audience: 'documents-api'
  })
}
```

Call `issueToken` only after authenticating the user. Verification throws for an invalid or expired token; catch that failure in the request handler and return an authentication error. Also check that the subject identifies an account allowed to perform the requested operation. Successful signature verification does not grant access to every account's documents.

Do not place private account data in a signed token just because its signature is valid. Anyone holding it can decode its claims. A browser session and a mobile API can use different transports while sharing the same authorization checks: an HttpOnly cookie for the browser, a Bearer token for the API client.

### Browser storage

`localStorage` and `sessionStorage` are accessible to scripts in their origin. A script can read a stored Bearer token. They are useful for non-secret UI state; they are not automatically sent as HTTP credentials.

```javascript
localStorage.setItem('theme', 'dark')
const theme = localStorage.getItem('theme')
```

This does not create a cookie the server can read. If server rendering needs that preference, send it explicitly or use a suitable preference cookie.

For a draft that should follow one tab rather than survive indefinitely, [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) is scoped by origin and top-level browsing context and survives reloads:

```javascript
sessionStorage.setItem('draft-title', titleInput.value)
const draftTitle = sessionStorage.getItem('draft-title')
if (draftTitle !== null) titleInput.value = draftTitle
```

This is local UI state. It does not authorize a saved draft on the server, and scripts can still read it. Keep server-side ownership checks when the draft is submitted. A hybrid application can store a theme locally, a cart in a session store, and durable orders in its database; those records have different lifetimes.

## Session Storage Options

The default express-session `MemoryStore` is not designed for production: it can leak memory and does not scale beyond one process. A restart also loses process-local session records.

A Redis or database store lets multiple application instances look up the same ID. Its durability, eviction, and expiration settings matter; choosing Redis does not automatically make records persistent. Keep the store's TTL consistent with your application policy. `connect-redis` uses cookie expiry when available, otherwise its configured TTL; by default, touching a session refreshes its TTL.

Avoid reading a session, editing a cart in memory, and writing the entire record from concurrent requests without checking your store's behavior. `resave: false` reduces one source of unnecessary writes, but does not make read-modify-write operations atomic.

<span id="security-best-practices"></span>

## Session Expiration and Security

Cookie `maxAge` in express-session is milliseconds. HTTP cookie `Max-Age` is seconds. Neither proves the server will reject a copied identifier after that deadline. Enforce idle and absolute expiry in the application/store.

`rolling: true` reissues the session cookie on responses and resets its expiry countdown. `req.session.touch()` updates the cookie's remaining lifetime; it is not an absolute lifetime check. Keep a creation time if the session must end regardless of activity.

After successful authentication, regenerate the session before assigning the authenticated identity. Integrate this fragment into a handler where authentication has already produced `user`:

```javascript
req.session.regenerate((error) => {
  if (error) return next(error)
  req.session.userId = user.id
  req.session.save((error) => {
    if (error) return next(error)
    res.redirect('/account')
  })
})
```

For logout, destroy the server record and clear the cookie using the same name and scope:

```javascript
app.post('/logout', (req, res, next) => {
  req.session.destroy((error) => {
    if (error) return next(error)
    res.clearCookie('__Host-session', {
      path: '/', secure: true, httpOnly: true, sameSite: 'lax'
    })
    res.status(204).end()
  })
})
```

Add your application's CSRF protection to cookie-authenticated mutations, including logout. HttpOnly prevents reading the cookie, not authenticated requests from injected scripts. SameSite is an additional restriction, not a complete authorization or CSRF solution. The old [`csurf` repository](https://github.com/expressjs/csurf) is archived; choose a maintained implementation rather than copying that previous middleware example.

[RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/) is still work in progress in the RFC Editor queue at this review date. Its cookie behavior is not an application-session storage API.

## Shopping Cart Example

Store product identifiers and validated quantities, then load authoritative prices and stock when preparing checkout. Do not accept a browser-supplied total as authoritative. A session identifies the cart; it does not deduplicate order creation. A retried POST after an uncertain failure needs an explicit [idempotency contract](https://howhttpworks.com/glossary/idempotency-key).

To debug a disappearing cart or login, inspect Set-Cookie on the first response and Cookie on the next request in DevTools. Then look up the session record on the instance serving that request. If the cookie is present but the record is absent, investigate store expiry, restart, or instance-local storage before rewriting the login flow.

Here are cart route fragments for the Express app above. They set an item's quantity rather than incrementing it, so a client repeating the same assignment does not intentionally double it. The bounds and SKU format are example application rules:

```javascript
app.use(express.json({ limit: '100kb' }))

app.get('/cart', (req, res) => {
  res.set('Cache-Control', 'no-store').json(req.session.cart || {})
})

app.post('/cart/items', (req, res, next) => {
  const { sku, quantity } = req.body || {}
  if (typeof sku !== 'string' || !/^[A-Z0-9-]{1,40}$/.test(sku) ||
      !Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
    return res.status(400).json({ error: 'Invalid SKU or quantity' })
  }
  const cart = req.session.cart || {}
  req.session.cart = { ...cart, [sku]: quantity }
  req.session.save((error) => {
    if (error) return next(error)
    res.status(204).end()
  })
})

app.delete('/cart/items/:sku', (req, res, next) => {
  const cart = { ...(req.session.cart || {}) }
  delete cart[req.params.sku]
  req.session.cart = cart
  req.session.save((error) => {
    if (error) return next(error)
    res.status(204).end()
  })
})
```

Mount the application's CSRF checks before the mutation routes. Validate the SKU against the catalog before checkout. These fragments still use whole-session writes: two overlapping requests can each read an old cart and overwrite the other's change. For concurrent carts, use a store operation or database transaction that protects the cart update rather than assuming session middleware serializes requests.

When diagnosing a session store problem locally, the middleware's [debug namespace](https://expressjs.com/en/resources/middleware/session/#debugging) can show creation, saving, and touching:

```bash
DEBUG=express-session node server.js
```

Use this in a controlled debugging environment: session diagnostics can include identifiers. Compare cookie receipt, record lookup, and save failure separately. A cookie visible in DevTools proves browser storage, not that the next application instance can retrieve the record.

## Related Resources

- [HTTP Session](https://howhttpworks.com/glossary/session)
- [Set-Cookie](https://howhttpworks.com/headers/set-cookie)
- [Cookie security](https://howhttpworks.com/guides/cookie-security)
- [CORS](https://howhttpworks.com/guides/cors)
