Skip to content

Sessions and Cookies - State in a Stateless Protocol

Table of Contents


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

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