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.cookieand sends it to the attacker (section 04). - Session fixation: the attacker sets the ID before the victim logs in (6.6 below).
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_baseand anyone can forge any session.
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.
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.
balance the server believes
cookie in browser
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
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.
expire_after: and a max age on Session#created_at.