Sessions and Cookies - State in a Stateless Protocol¶
Table of Contents¶
- HTTP Is Stateless - Why We Need Sessions
- Cookies : Set-Cookie , Cookie Header , Cookie Options
- Session Stores : Memory , Redis , Database
- Building Sessions from Scratch
- Session Fixation and Rotating Session IDs
- Security : httpOnly , Secure , SameSite , CSRF , Hijacking
HTTP Is Stateless - Why We Need Sessions¶
Every HTTP request is a fresh connection. The server has no idea whether the client asking for /profile is the same one that logged in 5 minutes ago unless there's a mechanism to link requests to a user session
That mechanism is a session cookie - a token stored in the browser that the server uses to look up stored state
Session flow: 1. User logs in with credentials 2. Server creates a session (unique ID + user data) in a store 3. Server sends Set-Cookie: session_id=abc123 in the response 4. Browser sends Cookie: session_id=abc123 on every subsequent request 5. Server looks up the session ID and knows who the user is
Cookies : Set-Cookie , Cookie Header , Cookie Options¶
Server sends a cookie to the client:
res.setHeader('Set-Cookie', 'session_id=abc123; HttpOnly; Secure; SameSite=Strict')
Browser sends cookies back on every request to that domain:
// Node.js parses this into req.headers.cookie
// Cookie: session_id=abc123; theme=dark
function parseCookies(header) {
if (!header) return {}
return header.split(';').reduce((cookies, pair) => {
const [key, ...val] = pair.trim().split('=')
cookies[key] = val.join('=')
return cookies
}, {})
}
const server = http.createServer((req, res) => {
const cookies = parseCookies(req.headers.cookie)
console.log('Session:', cookies.session_id)
res.end('Checked cookies')
})
Cookie options (append after the value):
| Option | Meaning | Security Impact |
|---|---|---|
HttpOnly | JS cant read this cookie (document.cookie) | Blocks XSS cookie theft |
Secure | Only sent over HTTPS | Prevents network sniffing |
SameSite=Strict | Only sent for same-site requests | Blocks CSRF |
SameSite=Lax | Sent for top-level GET navigations | CSRF protection (default in modern browsers) |
Domain=.example.com | Shared across subdomains | Wider attack surface |
Path=/app | Only sent for paths starting with /app | Scope restriction |
Max-Age=3600 | Expires in seconds | Session lifetime control |
Expires=Date | Absolute expiration | Alternative to Max-Age |
Sending multiple cookies:
res.setHeader('Set-Cookie', [
'session_id=abc123; HttpOnly; Secure',
'theme=dark; Max-Age=86400',
'tracking=false; Secure; SameSite=Lax'
])
Session Stores : Memory , Redis , Database¶
Memory store - simplest , dies on restart , not for production:
const sessions = new Map()
const memoryStore = {
get(sid) { return sessions.get(sid) || null },
set(sid, data) { sessions.set(sid, data) },
destroy(sid) { sessions.delete(sid) }
}
Redis store - production-ready , fast , survives restarts:
const { createClient } = require('redis')
const redisClient = createClient({ url: 'redis://localhost:6379' })
const redisStore = {
async get(sid) {
const data = await redisClient.get(`session:${sid}`)
return data ? JSON.parse(data) : null
},
async set(sid, data, ttl = 86400) {
await redisClient.set(`session:${sid}`, JSON.stringify(data), { EX: ttl })
},
async destroy(sid) {
await redisClient.del(`session:${sid}`)
}
}
Database store - slower but persistent with queryable data:
const db = require('./db')
const dbStore = {
async get(sid) {
const row = await db.query('SELECT data FROM sessions WHERE sid = $1 AND expires > NOW()', [sid])
return row.length ? JSON.parse(row[0].data) : null
},
async set(sid, data, ttl = 86400) {
const expires = new Date(Date.now() + ttl * 1000)
await db.query(
'INSERT INTO sessions (sid, data, expires) VALUES ($1, $2, $3) ON CONFLICT (sid) DO UPDATE SET data = $2, expires = $3',
[sid, JSON.stringify(data), expires]
)
},
async destroy(sid) {
await db.query('DELETE FROM sessions WHERE sid = $1', [sid])
}
}
Redis vs DB: Redis does sub-millisecond lookups. PostgreSQL does 1-10ms. If you serve 50K requests/second , that difference is the line between "fine" and "on fire"
Building Sessions from Scratch¶
const crypto = require('node:crypto')
function createSessionManager(store, options = {}) {
const ttl = options.ttl || 86400 // 24 hours default
const cookieName = options.cookieName || 'session_id'
return {
async getSession(req) {
const cookies = parseCookies(req.headers.cookie)
const sid = cookies[cookieName]
if (!sid) return null
return await store.get(sid)
},
async createSession(data) {
const sid = crypto.randomBytes(32).toString('hex')
await store.set(sid, data, ttl)
return sid
},
async saveSession(sid, data) {
await store.set(sid, data, ttl)
},
async destroySession(sid) {
await store.destroy(sid)
},
setCookie(res, sid) {
res.setHeader('Set-Cookie',
`${cookieName}=${sid}; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}; Path=/`)
},
clearCookie(res) {
res.setHeader('Set-Cookie',
`${cookieName}=; HttpOnly; Secure; SameSite=Strict; Max-Age=0; Path=/`)
},
// middleware
middleware() {
return async (req, res, next) => {
const session = await this.getSession(req)
req.session = session || {}
// save session on response finish
const originalEnd = res.end.bind(res)
res.end = (...args) => {
if (req._sessionChanged && req.session) {
// async fire-and-forget save
const sid = req._sessionId
if (sid) this.saveSession(sid, req.session)
}
return originalEnd(...args)
}
next()
}
}
}
}
// usage with the redis store from above
const sessions = createSessionManager(redisStore)
async function loginHandler(req, res) {
// validate credentials (omitted for brevity)
const user = { id: 1, role: 'admin' }
const sid = await sessions.createSession(user)
sessions.setCookie(res, sid)
res.end('{"logged_in": true}')
}
async function profileHandler(req, res) {
if (!req.session || !req.session.id) {
res.writeHead(401)
return res.end('{"error": "not authenticated"}')
}
res.end(JSON.stringify({ user: req.session }))
}
Session Fixation and Rotating Session IDs¶
Session fixation attack: attacker gives the victim a known session ID (via URL parameter or crafted link), then after the victim logs in , the attacker uses the same session ID to hijack the account
// VULNERABLE - attacker sets session_id=known123 before login
// victim logs in with that same session_id
// attacker uses known123 and has full access
Defense - rotate session ID on privilege elevation:
async function login(req, res) {
const user = await authenticate(req.body)
// destroy old session first
if (req.session && req._sessionId) {
await sessions.destroySession(req._sessionId)
}
// create NEW session after authentication
const sid = await sessions.createSession({
id: user.id,
role: user.role,
createdAt: Date.now()
})
sessions.setCookie(res, sid)
res.end('{"login": "success"}')
}
Always rotate session ID when: * User logs in * User role changes (admin promotion) * User changes password * Any privilege level change
Security : httpOnly , Secure , SameSite , CSRF , Hijacking¶
Session hijacking - attacker steals the session cookie and impersonates the user
Mitigations in priority order: 1. HttpOnly - stops XSS from reading document.cookie 2. Secure - ensures cookie only travels over HTTPS 3. SameSite=Strict - browser wont send cookie from other origins 4. Short session TTL - limits the damage window 5. Fingerprint the user agent + IP (imperfect but extra layer)
// bind session to user agent
async function createSession(data, req) {
const fingerprint = {
ua: req.headers['user-agent'],
ip: req.socket.remoteAddress
}
const sid = crypto.randomBytes(32).toString('hex')
await store.set(sid, { ...data, fingerprint }, ttl)
return sid
}
// verify on each request
function verifyFingerprint(session, req) {
return session.fingerprint?.ua === req.headers['user-agent']
}
CSRF (Cross-Site Request Forgery) - attacker tricks logged-in user into making unwanted requests. If session is validated by cookie alone , any site can make your browser send the cookie
// CSRF token pattern
const crypto = require('node:crypto')
function generateCSRFToken(session) {
if (!session._csrfToken) {
session._csrfToken = crypto.randomBytes(32).toString('hex')
}
return session._csrfToken
}
function csrfMiddleware(req, res, next) {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const token = req.headers['x-csrf-token']
const sessionToken = req.session?._csrfToken
if (!token || !sessionToken || token !== sessionToken) {
res.writeHead(403)
return res.end('{"error": "CSRF token mismatch"}')
}
}
next()
}
Complete secure cookie config:
function secureCookie(name, value, maxAge = 86400) {
return [
`${name}=${value}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
`Max-Age=${maxAge}`,
'Path=/'
].join('; ')
}
// production use
res.setHeader('Set-Cookie', secureCookie('session_id', sid, 3600))
prerequisites¶
web_04_middleware.md - Middleware stacks , req/res modification , error propagation
next => web_06_templating.md