Session Management Done Right: Cookies, SameSite and Where to Store Tokens
How to build secure web sessions: cookie flags, SameSite and CSRF, fixation and rotation, expiry and revocation, and why localStorage is a risky home for tokens.
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.
Cookie Attributes That Matter
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
Securekeeps the cookie off plaintext HTTP. Combine with HSTS so the browser never attempts HTTP.HttpOnlyhides 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.SameSitecontrols whether the cookie is sent on cross-site requests (more below).Path=/and noDomainattribute restrict the cookie to the exact host that set it. SettingDomain=example.commakes 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 enforceSecure,Path=/and noDomain, which defeats a class of cookie-injection tricks from sibling subdomains. Max-AgeorExpiresbound 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:
| Value | Cross-site subrequests (forms POST, fetch, images) | Top-level navigation via link (GET) |
|---|---|---|
Strict | Not sent | Not sent |
Lax | Not sent | Sent |
None | Sent (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.comandblog.example.comare the same site. A compromised or attacker-controlled sibling subdomain is not blocked bySameSite. This is another reason to avoid broadDomainscoping and to audit subdomains. Laxstill 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-Sitechecks on the server. Rejecting state-changing requests whereSec-Fetch-Siteiscross-siteis 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
fetchwith 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.
| Storage | Readable by JavaScript | Sent automatically | Main risk |
|---|---|---|---|
HttpOnly cookie | No | Yes | CSRF (mitigated by SameSite and tokens) |
localStorage | Yes | No | Any XSS can read and exfiltrate the token |
sessionStorage | Yes | No | Same as localStorage, scoped to the tab |
| JS memory (variable) | Yes, same page | No | XSS 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
HttpOnlycookie 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-Cookieon login and confirmSecure,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
Domainto 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=Laxto cover them. - Storing long-lived JWTs in
localStoragebecause it was convenient during development. - Treating
HttpOnlyas 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(orStrictwhere practical),Path=/and noDomain, and use the__Host-prefix. - SameSite is one layer against CSRF; add token or
Sec-Fetch-Sitechecks, 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
HttpOnlycookies or a backend-for-frontend over tokens inlocalStorage, and pair either with a strict CSP.