Authentication happens once. Everything after it depends on a small string the browser sends back with each request. If someone else obtains that string, they are the user, with no password and no second factor needed. Session management is therefore the part of an application that quietly carries all of its authentication guarantees, and it is where subtle mistakes turn into account takeover. This article covers how to design sessions that are hard to steal, hard to abuse and easy to revoke, and how to decide where tokens should live in the browser.

What a Session Actually Is

HTTP is stateless. After login the server creates a record of “this user is authenticated” and hands the client a reference to it. Two broad designs exist:

  • Server-side sessions. The cookie holds a random, opaque identifier. The server looks it up in a store (database, Redis) to find the user and state. Revocation is trivial: delete the record.
  • Self-contained tokens. The cookie or header holds a signed token carrying the claims (commonly a JWT). The server verifies the signature and trusts the contents. Revocation is hard without extra state (see JWT security).

For a browser-based application talking to its own backend, an opaque server-side session in a cookie is usually the simplest and safest default. Self-contained tokens earn their place when many independent services must verify identity without a shared store, and even then they should be short-lived.

Generating Session Identifiers

The identifier is a bearer credential, so treat it like a password you generate yourself.

  • Use a cryptographically secure random generator, not timestamps, counters or Math.random.
  • Use enough entropy; 128 bits or more is the common baseline, and 256 bits is cheap.
  • Make it opaque: no user ID, role or timestamp encoded in it.
  • Never accept identifiers from the URL, which leaks through logs, referrers and browser history.
import secrets

def new_session_id() -> str:
    # 32 bytes = 256 bits of entropy, URL-safe encoding
    return secrets.token_urlsafe(32)

Store only a hash of the identifier server-side if your session store could be read by someone who should not get live sessions (a database backup, for example). The lookup then hashes the presented value before comparing.

A session cookie should be set with all of the following, unless you have a specific reason not to:

Set-Cookie: __Host-sid=8f3a...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600
  • Secure keeps the cookie off plaintext HTTP. Combine with HSTS so the browser never attempts HTTP.
  • HttpOnly hides the cookie from JavaScript, so an XSS bug cannot read it directly. It does not stop XSS from making authenticated requests, but it prevents the token from being exfiltrated and reused elsewhere.
  • SameSite controls whether the cookie is sent on cross-site requests (more below).
  • Path=/ and no Domain attribute restrict the cookie to the exact host that set it. Setting Domain=example.com makes it available to every subdomain, which widens the blast radius if any one of them is vulnerable or can be taken over.
  • The __Host- prefix makes browsers enforce Secure, Path=/ and no Domain, which defeats a class of cookie-injection tricks from sibling subdomains.
  • Max-Age or Expires bound the lifetime. Without them it is a browser-session cookie, whose lifetime depends on browser restore behavior.

Example in Express with a server-side store:

import session from "express-session";
import RedisStore from "connect-redis";

app.set("trust proxy", 1); // required behind a TLS-terminating proxy

app.use(session({
  name: "__Host-sid",
  secret: process.env.SESSION_SECRET,
  store: new RedisStore({ client: redis }),
  resave: false,
  saveUninitialized: false,
  rolling: false,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 1000,
  },
}));

SameSite and CSRF

Cross-site request forgery works because browsers attach cookies automatically, even when the request was triggered by another site. An attacker’s page can submit a form to your application, and the victim’s session cookie goes along for the ride.

SameSite reduces this by restricting when cookies accompany cross-site requests:

ValueCross-site subrequests (forms POST, fetch, images)Top-level navigation via link (GET)
StrictNot sentNot sent
LaxNot sentSent
NoneSent (requires Secure)Sent

Lax is a sensible default for most applications: it blocks the typical cross-site POST while still letting a user follow a link to your site and arrive logged in. Strict suits high-value areas such as admin consoles, at the cost of showing a logged-out state when arriving from an external link.

Two important caveats:

  • “Site” is not “origin”. app.example.com and blog.example.com are the same site. A compromised or attacker-controlled sibling subdomain is not blocked by SameSite. This is another reason to avoid broad Domain scoping and to audit subdomains.
  • Lax still sends cookies on GET navigations. If any state-changing operation is reachable through GET, it remains exposed. State changes must use POST, PUT, PATCH or DELETE.

SameSite is a strong layer, not a replacement for anti-CSRF design. Add at least one more:

  • Synchronizer or signed double-submit tokens for form posts and cookie-authenticated APIs.
  • Origin and Sec-Fetch-Site checks on the server. Rejecting state-changing requests where Sec-Fetch-Site is cross-site is simple and effective in current browsers.
  • Custom header requirements for API calls, since a cross-site form cannot set arbitrary headers and a cross-origin fetch with one triggers a preflight.
function csrfGuard(req, res, next) {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
  const site = req.get("Sec-Fetch-Site");
  const origin = req.get("Origin");
  const ok =
    site === "same-origin" ||
    (!site && origin === "https://app.example.com");
  if (!ok) return res.status(403).send("Cross-site request blocked");
  next();
}

Fixation, Rotation and Lifetime

Session fixation happens when an attacker gets the victim to use a session identifier the attacker already knows, then waits for the victim to log in. If the server keeps the same identifier across the login boundary, the attacker’s copy becomes authenticated. The fix is simple and non-negotiable: issue a new identifier at login and invalidate the old one.

Rotate identifiers at every privilege change, not just login:

  • After authentication and after step-up (re-entering a password or MFA).
  • After a password or email change.
  • When roles or permissions change.
app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).end();

  // Regenerate the session ID to prevent fixation
  req.session.regenerate((err) => {
    if (err) return res.status(500).end();
    req.session.userId = user.id;
    req.session.authTime = Date.now();
    res.redirect("/");
  });
});

Lifetimes need two limits working together:

  • Idle timeout: the session ends after a period of inactivity. Choose shorter values for sensitive applications.
  • Absolute timeout: the session ends after a fixed time regardless of activity, forcing re-authentication. This bounds the damage of a stolen identifier.

“Remember me” should be a separate, long-lived, single-purpose token, stored hashed, rotated on each use and revocable, that can re-establish a session but not perform sensitive actions without recent authentication.

Logout and Revocation

A real logout deletes the server-side record, not just the cookie in the browser. Otherwise a copied identifier keeps working after the user believes they signed out.

Provide revocation in the situations where it matters:

  • Logout destroys the current session.
  • Password change or reset destroys all other sessions for that user.
  • “Sign out everywhere” and a visible list of active sessions with device and approximate location, so users can spot ones they do not recognize.
  • Admin-initiated revocation for incident response.

With self-contained tokens, revocation needs either very short lifetimes combined with refresh tokens you can revoke, or a denylist keyed on a token identifier. Both put state back on the server, which is a reason to consider whether opaque sessions would have been simpler.

Where to Store Tokens in the Browser

This is the question developers ask most, and the honest answer is a comparison of failure modes.

StorageReadable by JavaScriptSent automaticallyMain risk
HttpOnly cookieNoYesCSRF (mitigated by SameSite and tokens)
localStorageYesNoAny XSS can read and exfiltrate the token
sessionStorageYesNoSame as localStorage, scoped to the tab
JS memory (variable)Yes, same pageNoXSS can still use it; lost on reload

Prefer an HttpOnly, Secure, SameSite cookie for browser sessions. The risk it leaves open, CSRF, has well-understood and layered defenses. The risk localStorage leaves open, XSS reading the token, is hard to eliminate completely in a large application with third-party scripts, and the stolen token works from anywhere, for as long as it lives.

To be precise about what HttpOnly buys you: an attacker with script execution in the page can still send requests as the user from within the victim’s browser. What they lose is the ability to take the credential away and use it later from their own machine, and to survive the victim closing the tab. That difference matters for detection and containment. Pair it with a strict Content Security Policy and Trusted Types (see XSS, CSP and modern defenses).

Single-page apps and APIs

A SPA served from the same site as its API can use the same cookie approach. When the frontend and API are on different sites, or when native clients are involved, a common pattern is the backend-for-frontend: a small server component holds the real access and refresh tokens, and the browser only holds an HttpOnly session cookie to that component. The browser never sees an OAuth token at all (see API security and OAuth vulnerabilities).

If you must keep a bearer token in JavaScript:

  • Make it short-lived, with minutes rather than days of validity.
  • Keep it in memory rather than persistent storage where possible, and refresh through an HttpOnly cookie scoped to the refresh path.
  • Rotate refresh tokens on use and detect reuse, revoking the whole family when an old one reappears.
  • Treat any XSS finding as full account compromise and prioritize accordingly.

Binding and Anomaly Detection

You can raise the cost of stolen sessions without much user friction:

  • Log session metadata (creation time, last seen, user agent, coarse network) and expose it to users.
  • Re-authenticate for sensitive actions such as changing email, adding payment details or disabling MFA.
  • Flag sudden changes, for example a session that appears from two distant networks within minutes, and prompt for step-up rather than silently trusting it.
  • Avoid hard IP binding as the only control. Mobile users change networks legitimately, and strict binding causes support load that tempts teams to turn it off.
  • Consider device-bound credentials such as passkeys for login, which make phishing the initial login far harder, though a stolen post-login cookie remains a bearer credential.

Testing Your Own Application

Only test applications you own or have written permission to assess. A practical checklist:

  • Inspect Set-Cookie on login and confirm Secure, HttpOnly, SameSite, the __Host- prefix and a sensible lifetime.
  • Log in and compare the identifier before and after; it must change.
  • Log out, then replay the old cookie with curl; it must be rejected.
  • Change the password in one session and confirm the other sessions die.
  • Submit a state-changing request from a page on a different site; it must be refused.
  • Check that the identifier never appears in URLs, logs or error pages.
# Replay a captured session cookie after logout; expect 401 or a redirect to login
curl -i -H "Cookie: __Host-sid=PASTE_OLD_VALUE" https://app.example.com/account

Common Mistakes

  • Putting session identifiers or tokens in query strings.
  • Setting Domain to the apex and exposing the cookie to every subdomain.
  • Forgetting to regenerate the identifier at login.
  • Logging out by clearing the cookie client-side only.
  • Using GET for state-changing endpoints and trusting SameSite=Lax to cover them.
  • Storing long-lived JWTs in localStorage because it was convenient during development.
  • Treating HttpOnly as a substitute for fixing XSS.

Key Takeaways

  • Generate session identifiers from a secure random source, keep them opaque and never place them in URLs.
  • Set Secure, HttpOnly, SameSite=Lax (or Strict where practical), Path=/ and no Domain, and use the __Host- prefix.
  • SameSite is one layer against CSRF; add token or Sec-Fetch-Site checks, and never allow state changes over GET.
  • Rotate the identifier at login and at every privilege change, and enforce both idle and absolute timeouts.
  • Make logout and password change revoke sessions on the server, and give users a way to see and end other sessions.
  • Prefer HttpOnly cookies or a backend-for-frontend over tokens in localStorage, and pair either with a strict CSP.