guest@websec: ~/websec/06-sessions — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 06-sessions

06 · Sessions

HTTP forgets you between requests. A session is how the app remembers. Whoever holds the session cookie is you, as far as the server knows.

6.1 What a session is

After login the browser gets a cookie and sends it back every time. Two common designs:

  • Server-side: the cookie is just a random ID; data lives on the server.
  • Client-side: the data is in the cookie, signed and encrypted. Rails' default works this way.

Either way, the cookie is a bearer credential: present it and you're in.

6.2 Session hijacking

Steal the cookie, become the user. No password needed. The usual ways:

  • Sniffing on an unencrypted network (café Wi-Fi, a compromised router) when the site uses plain HTTP anywhere.
  • Shared computers where the previous user didn't log out.
  • XSS: injected JavaScript reads document.cookie and sends it to the attacker (section 04).
  • Session fixation: the attacker sets the ID before the victim logs in (6.6 below).
sim: café wi-fi sniffertoggle the defenses, then send a request

victim's browser

attacker's packet capture

Countermeasures, in every stack:

  • HTTPS everywhere. In Rails: config.force_ssl = true.
  • Cookie flags: Secure, HttpOnly, SameSite=Lax.
  • Give users a visible log out button that destroys the session on the server.
  • Prevent XSS, which bypasses all of the above.
# config/environments/production.rb
config.assume_ssl = true      # behind a TLS-terminating load balancer
config.force_ssl  = true      # HTTPS redirect + HSTS + Secure cookies

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: "__Host-myapp_session", secure: true, same_site: :lax, expire_after: 30.minutes
// Express + express-session
app.set("trust proxy", 1);                 // behind a load balancer
app.use(helmet.hsts({ maxAge: 31536000 }));
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: { secure: true, httpOnly: true, sameSite: "lax" },
  resave: false, saveUninitialized: false,
}));
app.use((req, res, next) =>              // force HTTPS
  req.secure ? next() : res.redirect(301, "https://" + req.headers.host + req.url));
# Django settings.py
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True

# Flask equivalent
app.config.update(SESSION_COOKIE_SECURE=True,
                  SESSION_COOKIE_HTTPONLY=True,
                  SESSION_COOKIE_SAMESITE="Lax")
force_ssl handles HTTPS, HSTS and Secure cookies (check it's on in production.rb). Session cookies are already HttpOnly and SameSite=Lax. Logout and XSS are on us.

6.3 Where session data lives

Rails keeps sessions in an encrypted cookie by default. Handy, but with catches:

  • Users keep a copy. They can't read or edit it, but they can send an old one back.
  • You can't revoke it. It works until it expires or you change the key.
  • One secret rules them all. Leak secret_key_base and anyone can forge any session.
sim: inside a session cookiepick a store

Anyone can decode and read this. They just can't change it.

Rails' default: unreadable and tamper-proof. Still replayable, still unrevocable without rotating the key.

The cookie is a random lookup key. Data stays on the server, and deleting the row logs the user out instantly.

Rule of thumb: cookies hold IDs, the database holds anything valuable.
Default CookieStore is encrypted but replayable and not revocable. The Rails 8 generator does better: a DB row per session, so you can log people out.

6.4 Rotating keys and algorithms

Someday you'll need a new secret. Swap it cold and everyone gets logged out. Instead, rotate: sign with the new key, still accept the old one for a while.

cookies_rotations does exactly this: accept the old key, upgrade cookies as users return.
# config/initializers/cookie_rotator.rb (Rails guide example)
# Move signed cookies from SHA1 to SHA256 without logging anyone out:
# new cookies use SHA256, old SHA1 cookies still work and get upgraded
Rails.application.config.action_dispatch.signed_cookie_digest = "SHA256"
Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies|
  cookies.rotate :signed, digest: "SHA1"
end
# the same rotate call can also accept cookies made with an old secret
// first secret signs new cookies; the rest are only accepted for verification
app.use(session({ secret: [process.env.SESSION_SECRET_2025, process.env.SESSION_SECRET_2024] }));
# Django 4.1+: new key signs, fallbacks still verify
SECRET_KEY = os.environ["SECRET_KEY_2025"]
SECRET_KEY_FALLBACKS = [os.environ["SECRET_KEY_2024"]]

6.5 Replay attacks on client-side sessions

Say the cookie holds 100 credits. The user saves a copy, spends it all, then puts the old cookie back. The signature is still valid, so... 100 credits again. Fix: keep balances in the database.

sim: the infinite-credits tricktry it with each store
session store:

balance the server believes

100

cookie in browser

Nothing stops cookie replay. Money, credits and permissions belong in the database, not session[].

6.6 Session fixation

Here the attacker plants a session ID before you log in, then rides along after.

Countermeasures

The fix: new session ID at login (and logout). In Rails: reset_session.

def create
  if user = User.authenticate_by(params.permit(:email_address, :password))
    cart = session[:cart]                 # keep what you need
    reset_session                         # new session id, old one discarded
    session[:user_id] = user.id
    session[:cart] = cart
    redirect_to root_path
  end
end
# Devise/Warden and the Rails 8 generator already do this on sign-in
app.post("/login", async (req, res, next) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).send("Invalid email or password.");
  const cart = req.session.cart;              // keep what you need
  req.session.regenerate(err => {             // new ID, old one destroyed
    if (err) return next(err);
    req.session.userId = user.id;
    req.session.cart = cart;
    res.redirect("/");
  });
});
# Django: auth.login() rotates the session key for you
from django.contrib.auth import login
login(request, user)            # calls request.session.cycle_key()

# Flask: there is no session ID with the default cookie store, but with
# a server-side store (Flask-Session) clear and rebuild on login:
cart = session.get("cart")
session.clear()
session["user_id"] = user.id
session["cart"] = cart
Devise and the Rails 8 generator already do this. Custom login code must call reset_session.

6.7 Session expiry

Sessions that never end give attackers forever. Expire them on the server, two ways:

  • Idle: log out after N minutes of nothing.
  • Absolute: log out after N hours, no matter what.
Rails sessions don't expire server-side by default. Add expire_after: and a max age on Session#created_at.
sim: idle vs absolute timeoutidle 30 min · absolute 8 h