Session Fixation and Cookie Security: The Vulnerabilities Developers Still Get Wrong

Session fixation lets attackers pre-set a victim's session identifier before authentication, then hijack the authenticated session. Cookie security attributes — Secure, HttpOnly, SameSite — prevent a separate set of attacks that are easy to introduce accidentally. Here's how to get both right.

Session management sits at the core of web application security. Get it right and authentication works as intended. Get it wrong and an attacker can take over authenticated sessions without ever knowing a user’s password.

There are two distinct problems here that developers frequently conflate: session fixation (an attacker controlling the session ID before authentication) and cookie attribute misconfiguration (an attacker accessing or using session cookies they shouldn’t be able to reach). They’re related, but they need separate fixes.

Session Fixation

What It Is

In a normal authentication flow, the server assigns a session identifier before the user logs in, then — critically — replaces it with a new identifier when authentication succeeds. If the server keeps the same session ID across the login boundary, it creates a fixation vulnerability.

The attack works like this:

  1. Attacker gets a valid pre-authentication session ID from the application (typically by visiting the site themselves).
  2. Attacker tricks the victim into making requests using that same session ID — via a crafted link (https://app.example.com?sessionid=ATTACKER_KNOWN_VALUE), a shared network (ARP poisoning to inject the cookie), or through a subdomain the attacker controls.
  3. Victim authenticates. The session ID is promoted to an authenticated session without being regenerated.
  4. Attacker now holds a session ID that maps to an authenticated session. They use it to access the application as the victim.

The Fix

Regenerate the session ID at every authentication boundary. Full stop. Not “consider regenerating,” not “regenerate if the IP changes.” Always.

PHP:

// After successful authentication
session_regenerate_id(true); // true = delete old session data

Express (Node.js with express-session):

req.session.regenerate((err) => {
  if (err) {
    return next(err);
  }
  // Re-attach user data to the new session
  req.session.user = authenticatedUser;
  next();
});

Django: Django’s login() function calls cycle_key() which cycles the session key on login. If you’re doing custom authentication, call it explicitly:

from django.contrib.auth import login
login(request, user)  # Handles session cycling internally
# OR manually:
request.session.cycle_key()

Spring Security: Session fixation protection is on by default. The default strategy is changeSessionId (servlet 3.1+). Verify your configuration isn’t overriding this:

http.sessionManagement()
    .sessionFixation().changeSessionId(); // Don't set .none()

When to Regenerate Beyond Login

Session IDs should also be regenerated when privilege level changes — when a user elevates to admin mode, activates a high-sensitivity feature, or transitions from a read-only to a write-enabled context. Consider any state transition that grants additional authority as a new authentication boundary.

The URL-Based Session ID Problem

Session IDs in URLs (?sessionid=xyz) are particularly exploitable for fixation because they’re trivially shareable. They also leak in Referer headers, browser history, and server logs. Avoid URL-based session tokens wherever possible. If you must support them (legacy integrations, download links that need to carry session context), ensure they expire quickly, are single-use, and cannot be set by the user via a query string parameter.

Session cookies have four attributes that directly affect their security posture. All four should be set correctly, but they address different attack vectors.

Secure

The Secure flag tells the browser to only send the cookie over HTTPS connections. Without it, the cookie is sent over HTTP, which exposes it to any network observer with MITM capability.

Setting Secure on all session cookies is non-negotiable for production. In development you might work over HTTP, but the flag should be enforced in production configuration, not application code:

Express:

app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    // In production, secure: true
  }
}));

Django:

SESSION_COOKIE_SECURE = True  # settings.py
CSRF_COOKIE_SECURE = True     # also set this

HttpOnly

The HttpOnly flag prevents JavaScript from accessing the cookie via document.cookie. This defends against session theft via XSS: even if an attacker successfully injects script into your page, they cannot read the session cookie.

// Express
cookie: { httpOnly: true }

// Set-Cookie header manually:
// Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=Lax

Note: HttpOnly doesn’t prevent XSS — it only limits what an attacker can do with XSS. Don’t use it as a substitute for input sanitisation and output encoding.

SameSite

SameSite controls whether the browser sends the cookie on cross-origin requests. It’s the primary modern defense against CSRF (Cross-Site Request Forgery).

Three options:

  • Strict: Cookie only sent on same-site requests. Breaks flows where users arrive at your app via a link from another site (e.g., clicking “Go to your account” in an email — the first page load won’t have the session cookie).
  • Lax (recommended default): Cookie sent on same-site requests and top-level navigation (GET requests). Blocks most CSRF scenarios while not breaking normal link-following. The browser default in most modern browsers when SameSite isn’t set.
  • None: Cookie sent on all cross-origin requests. Requires Secure. Use only for genuinely cross-origin use cases (OAuth, embedded content, cross-site SSO flows). This is the setting you must explicitly choose, not something you accidentally get.

For most web applications: use Lax for session cookies.

# Django
SESSION_COOKIE_SAMESITE = 'Lax'
// Express
cookie: {
  sameSite: 'lax',
  secure: true,
  httpOnly: true,
}

The Domain attribute controls which hosts can receive the cookie. Setting Domain=.example.com (note the leading dot, or the equivalent in modern specs) allows all subdomains to receive the cookie. This creates an attack vector called cookie tossing.

Cookie tossing: If any subdomain of your root domain is compromised or attacker-controlled (common in large organisations with wildcard subdomains or customer subdomain features), the attacker can set a cookie for the parent domain from that subdomain. This cookie then gets sent to your main application, potentially overriding legitimate session cookies or injecting malicious values.

# Attacker controls attacker.example.com
# Sets via JavaScript: document.cookie = "sessionid=ATTACKER_VALUE; Domain=example.com; Path=/"
# Now every request to example.com and *.example.com carries this cookie

Mitigation:

  • Don’t set the Domain attribute at all if the cookie is only for the current host — the default scope (current host only, no subdomains) is safer.
  • If you need subdomain access, scope it as narrowly as possible.
  • Implement cookie name prefixes: __Host- prefix forces Secure, no Domain, and Path=/. __Secure- forces Secure. These prefixes are validated by browsers and prevent cookie tossing attacks against those cookie names.
Set-Cookie: __Host-sessionid=abc123; Secure; HttpOnly; SameSite=Lax; Path=/

Session Expiry and Invalidation

Correctly setting cookie attributes doesn’t help if sessions never expire. Session security requires:

Idle timeout: Sessions with no activity for N minutes should be invalidated server-side. Don’t rely only on cookie expiry — delete the session from the server’s session store.

Absolute expiry: Even active sessions should have a maximum lifetime. A session opened twelve hours ago is stale even if activity has been continuous.

Logout invalidation: When a user logs out, delete the session server-side. Clearing the cookie client-side is not sufficient — the session token is still valid on the server if you only clear the cookie, and an attacker who already copied the cookie can continue using it.

Concurrent session control: Consider whether your application should allow multiple simultaneous sessions for the same account. For high-sensitivity applications, forcing logout of all other sessions on new login reduces the impact of session theft.

Testing Your Implementation

Run through this checklist before deploying session management changes:

  1. Log in. Capture the session cookie. Log out. Verify the cookie is rejected if submitted manually — session invalidated server-side.
  2. Log in with a pre-set session ID (set via developer tools before authentication). Verify the session ID changes on login.
  3. Make a cross-origin POST request to a state-changing endpoint. Verify it fails without the CSRF token (or with SameSite=Lax, that cross-origin POSTs don’t carry the session cookie).
  4. Check the Set-Cookie header for Secure, HttpOnly, SameSite, and absence of a domain attribute wider than necessary.
  5. Try setting a cookie with your session cookie name from a subdomain. Verify the server rejects or ignores it correctly.

These checks take less than twenty minutes and catch the most common session management mistakes before they reach production.