Guide
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.
On this page
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:
Set-Cookie: __Host-session=opaque-session-id; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store
On an eligible subsequent request:
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.
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. Supply SESSION_SECRET and REDIS_URL through your deployment configuration.
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 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.
This fragment uses jsonwebtoken’s sign and verify APIs 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:
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.
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 is scoped by origin and top-level browsing context and survives reloads:
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.
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:
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:
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 is archived; choose a maintained implementation rather than copying that previous middleware example.
RFC 6265bis 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.
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:
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 can show creation, saving, and touching:
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
Check your understanding
Design a session cookie and a preference cookie on purpose
Use the cookie builder to compare a locked-down session cookie with a less sensitive UI preference cookie.
- Create a session cookie with `HttpOnly`, `Secure`, `Path=/`, and an intentional `SameSite` policy.
- Create a second cookie for a theme or locale preference and compare which attributes can be relaxed safely.
- Notice how the auth cookie is optimized for abuse resistance, while the preference cookie is optimized for convenience.
Look for: The session cookie should be harder for scripts and cross-site requests to misuse than the preference cookie.
Why it matters: State management is mostly about deciding which data deserves stricter transport and storage guarantees.
Pick the safer browser session pattern
A server-rendered web app needs to remember who the user is across page loads. Which default pattern is usually safer for the main session identifier?
Frequently asked questions
Why is HTTP stateless?
HTTP does not automatically maintain application state or associate requests with a logged-in identity. Applications add that association using credentials such as cookies or tokens.
How do sessions work in HTTP?
A common server-side implementation sends an opaque session ID in Set-Cookie. The browser sends it on eligible requests, and the server retrieves and validates the associated record.
What is the difference between cookies and sessions?
Cookies are browser-managed data. Sessions are application state associated with requests; they can use server-side records, signed cookies, or tokens. A session cookie is specifically a cookie without Expires or Max-Age.
What are alternatives to cookie sessions?
Alternatives include signed cookie state or validated tokens carried in cookies or Authorization headers. JWTs can be revoked using server-side checks; browser storage is a separate security choice.
Sources
Related
Cookie-Based vs Session-Based Authentication
Compare cookie-based and session-based authentication. Understand where state lives, security tradeoffs, scalability implications, and when to use each approach.
JWT vs Session Cookies: Logout and Security
Compare stateless JWT bearer tokens with opaque session cookies: revocation, rotation, request size, browser storage, CSRF, XSS and algorithm validation.
Request and Response Lifecycle
Learn how HTTP requests travel from browser to server and back. Understand DNS resolution, TCP connections, request/response flow, and the complete lifecycle.
HTTP Status Codes: Full List with Meanings
Every HTTP status code class from 1xx to 5xx with what each code means and when to return it, including the ones you meet most: 200, 301, 304, 404 and 500.
WebSockets over HTTP: Handshakes, Proxies and Auth
Trace a WebSocket HTTP handshake, verify Sec-WebSocket-Accept, configure nginx, secure browser authentication, and debug idle disconnects and close codes.
Content Negotiation: Accept, Languages and Caches
Implement HTTP content negotiation with Accept headers, q-values, Vary and 406 responses, with Express, Django and nginx examples and language SEO guidance.