Session management: cookies, tokens, timeouts, and revocation

How web sessions are built and defended: cookie attributes (HttpOnly, Secure, SameSite, Domain, Path), server-side session tokens versus JWTs and their trade-offs, idle and absolute timeouts, forced re-authentication, how to actually revoke a session, and the controls against CSRF, XSS, and session hijacking.

“Logout doesn’t log me out”

The bug report lands on David’s desk with a short title: “Logout doesn’t log me out.” A customer signed out of the web app on a shared computer at a library, then, out of curiosity, pressed the browser’s back button and found themselves inside their account again. David reproduces it in five minutes. The logout button clears the cookie in the browser, but the session it pointed to is still alive on the server, and the session token is a JWT that stays valid for eight hours anyway. Anyone who copied that cookie, or who sits down at that computer before the browser cache clears, can carry on as the customer.


Authentication is a moment, the instant someone proves who they are. HTTP, though, is stateless. Without help, the server would forget that proof on the very next request and ask for the password on every click. The session is what remembers that this browser has already authenticated, so the person proves it once and stays recognized. That makes the session one of the most attacked assets in any application: a valid session ID carries an authenticated identity, and whoever holds it is the user, no password needed. The SSO article kept bumping into this, with the master session, the independent application sessions, and the trouble with logout. This article opens that box: how sessions are built from cookies and tokens, how long they should live, how to end them, and how to defend them against the attacks aimed at them.


It’s a technical article, with concrete attributes, headers, and trade-offs. The standard reference is the OWASP Session Management Cheat Sheet, which goes well with everything below.


Cookies and their attributes

In browser apps, the session nearly always travels in a cookie, a small value the server sets and the browser sends back automatically on later requests. The value itself should be an opaque, high-entropy identifier. Most of the security lies in the attributes set with it, and missing one hands an attacker a lever.

Set-Cookie: sid=9f2a...e71; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800
AttributeWhat it doesWhy it matters
HttpOnlyHides the cookie from JavaScript (document.cookie)An XSS payload can’t read the session ID; the single most important flag
SecureSends the cookie only over HTTPSStops the session ID from being intercepted in cleartext
SameSiteControls whether it’s attached to cross-site requestsThe main browser-level defense against CSRF
DomainWhich hosts receive the cookieToo broad a domain leaks it to subdomains that shouldn’t have it
PathWhich URL paths receive the cookieNarrows the cookie; a small defense-in-depth control
Max-Age / ExpiresHow long the cookie livesLeave it out for a session cookie that dies with the browser; set it for persistence

SameSite deserves a closer look, because it’s your front-line CSRF control. With Strict, the cookie is never sent on any cross-site request, not even a top-level navigation. It’s the strongest setting, but following a link from an email or another site lands the user signed out, which can hurt the experience. With Lax, the cookie is sent on top-level GET navigations, such as clicking a link, but not on cross-site POSTs, iframes, or background requests. It’s the pragmatic default for session cookies: it blocks the classic CSRF route and keeps normal navigation working. With None, the cookie goes on every cross-site request and must be paired with Secure. Use it only when you really need cross-site cookies, as in some embedded or federated flows, and compensate with anti-CSRF tokens.


For a first-party session cookie, a safe starting point is HttpOnly; Secure; SameSite=Lax; Path=/, a narrow Domain, and no persistent Max-Age unless you deliberately want “remember me.” Loosen an attribute only when a concrete requirement forces it, and write down why.


Server-side session tokens versus JWTs

Behind the cookie sits a choice that shapes everything about lifetime and revocation: is the session stateful, kept on the server, or stateless, self-contained?


With a server-side session, the cookie holds a random opaque ID that means nothing by itself. It’s a key into session state the server stores in memory, in Redis, or in a database, and on each request the server looks up the ID and reads the identity, roles, and metadata attached to it. The server is the source of truth, so you can revoke instantly by deleting the record, store rich state that changes over time, and lose nothing if the cookie is intercepted, since it’s just a random string. The cost is a server-side store and a lookup on every request, and that store has to scale and be shared across instances.


A JWT used as a session is a signed, self-describing token that carries the claims, subject, roles, expiry, inside itself. The server checks the signature and reads the claims without any lookup, and nothing is stored on its side. It’s stateless and scales horizontally with no session store, and it fits naturally into the OAuth and OIDC world. The cost is revocation. Because the server stores nothing, there’s nothing to delete, and a stolen JWT stays valid until it expires. Claims can also go stale, so a role removed mid-session still shows in the token, and a bigger token travels on every request.


Server-side tokenJWT
Source of truthThe server’s storeThe token itself
RevocationInstant (delete the record)Hard (wait for expiry or use a denylist)
ScalingNeeds a shared storeStateless, easy
Freshness of stateAlways currentCan be stale until reissued
Best fitSensitive web apps that need controlAPIs and microservices that need scale

The common anti-pattern is exactly what David found: a single JWT with a lifetime of hours or days used as the whole session. It can’t be revoked, so a stolen copy works as a master key until it expires. If you use JWTs, keep the access token short-lived, a matter of minutes, and pair it with a revocable refresh token backed by server state, which brings back the control JWTs otherwise lack. For high-value web apps, opaque server-side sessions are often simply the safer default.


Lifetime: idle and absolute timeouts

A session shouldn’t live forever. Two independent clocks decide how long it lasts, and mature systems run both.


stateDiagram-v2
  accTitle: Web session lifecycle with idle and absolute timeouts
  accDescr: A session starts anonymous. Successful login moves it to active. From active, inactivity moves it toward idle; any request returns it to active and resets the idle clock. Reaching the idle timeout or the absolute timeout moves the session to ended. Logout or administrative revocation also moves it to ended from active. From ended, re-authentication returns the user to active with a new session.
  [*] --> Anonymous
  Anonymous --> Active: login (authenticate)
  Active --> Active: request resets idle clock
  Active --> Idle
  Idle --> Active: new request
  Idle --> Ended: idle timeout reached
  Active --> Ended: absolute timeout reached
  Active --> Ended: logout / revoked
  Ended --> Active: re-authenticate (new session)
  Ended --> [*]
  state "Idle (no activity)" as Idle
The session lifecycle. Signing in moves the user from anonymous to active. Inactivity slides toward the idle-timeout edge; any request resets it to active. Two things end the session: reaching the idle timeout, or hitting the absolute timeout that caps total lifetime from sign-in. Logout or an administrator's action can revoke it at any point. An ended session forces re-authentication to start a new one.

The idle timeout, also called sliding, ends the session after a period of inactivity and resets on every request. Someone actively working is never interrupted, and a session left open on an unattended machine, like the library computer, dies on its own. Typical values run from a few minutes for sensitive apps to tens of minutes for ordinary ones. The absolute timeout ends the session a fixed time after sign-in, however active the user is. It caps the total lifetime and forces periodic re-authentication, so even a session in constant use, or constantly being used by a thief, can’t live forever. Typical values are hours for web apps and less for privileged contexts.


Use them together: a short idle timeout catches abandonment, and a longer absolute cap forces a fresh proof of identity on a schedule. Beyond timeouts, deliberately force re-authentication for risky actions. This is the step-up from the factors article, applied to the session: viewing data can ride the existing session, but changing a password, adding a payee, or exporting records should demand a fresh factor even while the session is valid.


There’s no universal number, but the app’s risk sets the range. High-sensitivity apps such as banking and administration consoles lean toward idle timeouts in the low tens of minutes and absolute caps of a few hours; ordinary productivity apps tolerate longer on both; and the more sensitive the action, the more you rely on step-up rather than on a permissive global timeout. The mistake is one generous timeout for everything, which suits the lowest-risk flow and leaves the highest-risk one exposed for just as long. Whatever you choose, enforce it on the server. A timer in the browser that merely hides the interface leaves the session alive and replayable, which is no timeout at all. And document the reasoning, because “why 30 minutes?” is a question auditors reliably ask.


Revocation: ending a session now

Timeouts end sessions eventually. Revocation ends a particular session now: at logout, when a compromise is detected, when an administrator disables an account, when someone leaves the company. How hard it is depends entirely on the choice between stateful and stateless.


Server-side sessions revoke cleanly. Delete or flag the record and the next request fails validation, which is the decisive operational advantage of opaque sessions and why incident response and offboarding prefer them. Stateless JWTs have no record to delete, so you need another mechanism. You can keep lifetimes short and make refresh tokens revocable: the access token expires in minutes, and revoking the refresh token stops it being renewed, so the exposure window is bounded by the access token’s lifetime rather than closed entirely. You can keep a denylist of revoked token IDs (jti) checked on each request, which works but brings back the very lookup JWTs were meant to avoid. Or you can keep a token version, a “not before” value per user, and bump it to invalidate all of that user’s tokens at once, which is useful for “sign out everywhere” and after a password change.


The honest summary is that instant, reliable revocation always needs some server-side state. Fully stateless sessions trade revocability for scale, which is fine for low-risk APIs and dangerous for valuable accounts.


Session fixation and rotating the ID

One attack deserves separate treatment because its defense is a habit rather than a flag. In session fixation, the attacker first obtains a valid session ID, which many apps hand to anonymous visitors, and then tricks the victim into signing in under that same ID, through a planted link or a cookie set in advance. Because the app kept the ID stable across sign-in, the attacker, who already knows it, now shares the victim’s authenticated session.


The fix is simple and non-negotiable: rotate the session identifier whenever privileges change, and above all right after a successful sign-in. Issue a brand-new ID and invalidate the old one, and the attacker who fixed it is left holding a dead value. The same applies at any step-up or role elevation. Whenever the session’s trust level rises, the identifier should change, so anything captured at a lower level can’t ride the upgrade.


Persistent sessions: “remember me” done safely

A plain session cookie dies with the browser. “Remember me” extends that on purpose with a persistent cookie, so the user doesn’t have to sign in every day. It’s convenient, and risky if done naively, because you’re now storing a long-lived credential on a device you don’t control.


Use a separate, long-lived remember-me token, distinct from the short session token, rather than a session cookie with a huge Max-Age. Make it a high-entropy random value stored on the server in hashed form, so it can be revoked and a database leak doesn’t expose usable tokens. Rotate it on every use, and treat the reuse of an old token as a sign of theft, since someone replayed a token you’d already rotated away, then invalidate the whole chain. And never give a remembered session the same power as a fresh one. A remembered user can browse; changing the password, payment details, or security settings still triggers step-up. “Remember me” restores convenience, not full trust.


Beyond the browser: mobile apps and APIs

Not every session is a cookie. Mobile apps and API clients usually carry a bearer token, often an OAuth access token, in an Authorization: Bearer header. The idea is the same, a credential proving an authenticated session, but the defenses shift. SameSite and CSRF largely fall away for pure bearer-token APIs, because the browser isn’t attaching a cookie automatically; the client sends the token explicitly, although designs mixing cookies and tokens still need CSRF care. Storage becomes the risk: a token in the browser’s localStorage is readable by any XSS, which is why sensitive web apps often keep the session in an HttpOnly cookie instead, and on mobile, tokens belong in the platform’s keystore or keychain, not in plain files. Short-lived access tokens with refresh tokens are the norm, echoing the JWT discussion. And DPoP (RFC 9449) hardens bearer tokens by tying them cryptographically to the client’s key, so a stolen token can’t be replayed from another device, which goes straight at the “whoever holds it can use it” weakness covered in the frontier article. Whether the session lives in a cookie or a header, the job is the same: keep it from being read, replayed, or outliving its welcome.


The attacks: hijacking, XSS, and CSRF

The session layer has three signature attacks, and knowing the controls for each is core practitioner knowledge.


Session hijacking means stealing a valid session ID and replaying it to impersonate the user. It gets stolen through interception over cleartext, a cookie readable by JavaScript, IDs leaking into URLs or logs, or being lifted from a shared machine. The controls are Secure and HttpOnly cookies, TLS everywhere, never putting session IDs in URLs, rotating the ID after sign-in, tying sessions to context such as device and rough network and checking again on anomalies, and short timeouts that shrink the replay window.


Cross-site scripting (XSS) injects attacker script into a page so that it runs in the victim’s browser with the site’s privileges. If the session cookie isn’t HttpOnly, the script can read and exfiltrate it outright; even with HttpOnly, it can ride the session to make authenticated requests. The controls are contextual output encoding and input validation to stop injection at the source, a strong Content-Security-Policy to limit which scripts can run, and HttpOnly so the cookie itself stays unreadable. XSS is why HttpOnly is non-negotiable, but it only hides the cookie. It doesn’t fix the injection, which you have to close as well.


Cross-site request forgery (CSRF) tricks a signed-in user’s browser into sending an unwanted authenticated request to your site, such as a hidden form or image tag firing a state-changing POST. The browser attaches the session cookie automatically, so the request looks legitimate. The controls are SameSite=Lax or Strict cookies, which block the classic cross-site send; anti-CSRF tokens, an unpredictable value per session or per request that the server checks on every state-changing request; and checking Origin or Referer on sensitive endpoints. Because SameSite has edge cases and gaps in older browsers, layer it with anti-CSRF tokens for anything that changes state.


These defenses interlock, and none works alone. HttpOnly limits XSS damage but doesn’t stop XSS. SameSite blunts CSRF but doesn’t replace anti-CSRF tokens. Short timeouts shrink hijacking windows but don’t prevent theft. Real session security is the combination of secure cookie flags, XSS prevention through encoding and CSP, CSRF tokens, ID rotation, sensible timeouts, and server-side revocation. Remove any one and a specific attack opens up again.


Where the session store lives

Choosing server-side sessions raises a practical question that JWT fans like to press: where does the state live, and does it scale? On a single server, in-memory sessions are trivial. Once you run several instances behind a load balancer, a session held in one server’s memory is invisible to the others, and a request routed elsewhere looks signed out.


There are three common answers. Sticky sessions pin each user to one server, which is simple but fragile, since that server’s failure signs everyone on it out, and it complicates scaling. A shared session store such as Redis, Memcached, or a database holds sessions centrally so any instance can read them. This is the standard modern approach: it keeps instant revocation, survives restarts, and scales horizontally, at the cost of a fast network lookup per request and a store you have to keep available. And signed state on the client, the JWT end of the spectrum, removes the store entirely at the price of revocability. “Server-side sessions don’t scale” is outdated folklore. A shared store handles enormous volumes while keeping the control that makes opaque sessions attractive, so the real trade is operational, running a store, against security, keeping revocability, and for most high-value apps the store wins.


What logout should actually do

The SSO article showed how hard logout is across applications. Within a single app it still has to be done properly, or it’s theater, as David’s bug report proved. A correct logout invalidates the session on the server, deleting or flagging the record, so the credential is dead even if a copy of the cookie survives. Clearing the cookie in the browser isn’t enough: if the session behind it is still valid, a captured cookie replays straight back in. Good logout also clears the cookie on the client so the next visit starts clean, and ideally offers “sign out everywhere,” invalidating all of a user’s sessions across devices, which the token-version technique makes cheap. And remember the boundary from SSO: signing out of the app isn’t the same as signing out of the identity provider, and the interface should be honest about which one just happened.


David’s fix was to move the web app to opaque server-side sessions in Redis, delete the session record on logout, and give the remaining API JWTs a ten-minute lifetime with revocable refresh tokens. The back button on the library computer now shows a sign-in page.


A session’s life, start to finish

One flow ties it together. Sara signs in to her online bank. She authenticates with a passkey, and on success the server issues a fresh session ID, rotating away the anonymous one, and sets it as an HttpOnly; Secure; SameSite=Lax cookie, while recording a server-side session with her identity, roles, and timestamps. As she browses, each request brings the cookie back, the server looks it up, and her 15-minute idle clock keeps resetting. When she starts a wire transfer, the app doesn’t trust the standing session; it forces step-up, re-checking her passkey right then, and rotates the session ID again for the elevated session. When she walks away, the idle timeout ends the session on the server after 15 minutes, and her next click lands on the sign-in page; even if she’d stayed active, an eight-hour absolute timeout would eventually have required a fresh sign-in. And when she reports her laptop lost that evening, support revokes her sessions on the server, so any cookie still on the laptop fails on the next request. Because the bank chose server-side sessions, that’s instant. A design built on stateless JWTs would have to wait out the token’s lifetime or consult a denylist.


A hardening checklist

A short list to run against any application:


  • Cookies set with HttpOnly, Secure, SameSite=Lax or Strict, a narrow Domain and Path, and no needless persistence.
  • Session IDs that are high-entropy and opaque, rotated at sign-in and on every privilege change, and never placed in a URL.
  • A short idle timeout and a longer absolute cap, both enforced on the server, plus step-up for sensitive actions within a valid session.
  • A reliable server-side way to end a session now; for JWTs, short lifetimes and revocable refresh tokens.
  • XSS defenses (output encoding, input validation, a strong CSP) and CSRF defenses (SameSite plus anti-CSRF tokens on state-changing requests).
  • TLS everywhere, with session IDs never sent or logged in cleartext.
  • Separate, rotating, revocable remember-me tokens with reduced privilege, and for non-browser clients, tokens in secure storage, short-lived access tokens with refresh tokens, and DPoP where it fits.

Recap

The session is where authentication lives on after the moment of sign-in, and it’s a prime target, so it deserves deliberate engineering. Cookies carry it, and their security lies in the attributes: HttpOnly, Secure, SameSite, and a tight Domain and Path. Server-side tokens versus JWTs is a trade of revocability and control against statelessness and scale, and a long-lived JWT as the whole session is an anti-pattern. Idle and absolute timeouts work together, one catching abandonment and the other capping lifetime and forcing periodic re-authentication. Revocation is instant with server-side state and genuinely hard with stateless JWTs, so “sign out now” always needs some state on the server, plus ID rotation to defeat fixation. And hijacking, XSS, and CSRF each have specific, interlocking controls, none of them sufficient alone.



Three questions to test yourself

  1. A colleague proposes storing the whole user profile and roles in a JWT valid for 24 hours “to avoid a session database.” Describe two concrete situations where this goes wrong, and how you’d redesign it while keeping most of the scalability.
  2. Design the timeout policy for an online banking app: choose an idle timeout and an absolute timeout, and name two actions that should force step-up re-authentication even in the middle of a session. Justify each number.
  3. An app sets its session cookie without HttpOnly and without SameSite. Name the attack each missing attribute exposes, and the additional control you’d add beyond fixing the flags.

Hands-on exercises

  1. Inspect a real session cookie. On a site you use, open the developer tools and read the session cookie’s attributes. Note which of HttpOnly, Secure, and SameSite are set, and write one sentence on the risk of any that are missing.
  2. Decode versus look up. Find a JWT (many apps keep one in local storage or send it in an Authorization header) and decode its payload with a JWT debugger. Find its exp. If this token were stolen right now, how long would it stay valid, and could the server stop it sooner?
  3. Trace a logout. Sign out of a web app with the network tab open. Work out whether the session ended on the server (the old cookie now fails) or was only cleared in the browser (the value would still work if replayed). Which did the app do?

Frequently asked questions

What do the HttpOnly, Secure, and SameSite cookie attributes do?

HttpOnly hides the cookie from JavaScript, so an XSS payload can't read the session ID. Secure sends the cookie only over HTTPS, preventing interception in cleartext. SameSite (Strict, Lax, or None) controls whether the cookie is attached to cross-site requests, which is the main browser-level defense against CSRF. A session cookie should be HttpOnly, Secure, and SameSite=Lax or Strict.

What is the difference between a server-side session token and a JWT for sessions?

A server-side session token is an opaque random ID pointing to session state stored on the server, so the server is the source of truth and can revoke instantly by deleting the record. A JWT is a self-contained, signed token the server validates without a lookup, which scales well but is hard to revoke before it expires because there's nothing stored to delete. The trade-off is revocability and control against statelessness and scale.

What is the difference between an idle timeout and an absolute timeout?

An idle (sliding) timeout ends the session after a period of inactivity and resets on each request, so an active user stays signed in. An absolute timeout ends the session a fixed time after sign-in regardless of activity, capping its total lifetime. Robust systems use both: a short idle timeout to catch abandoned sessions and a longer absolute cap to force periodic re-authentication.

How do you revoke a session before it expires?

With server-side sessions, you delete or flag the session record and the next request fails validation immediately. With stateless JWTs there's nothing to delete, so you either keep token lifetimes short with revocable refresh tokens, or maintain a denylist or token-version check, which brings back a server lookup. Instant revocation always requires some server-side state.

What are the main attacks against sessions?

Session hijacking (stealing a valid session ID through interception, XSS, or an insecure cookie), cross-site scripting (XSS, injecting script that reads or abuses the session), and cross-site request forgery (CSRF, tricking the browser into sending an authenticated request). The core defenses are HttpOnly, Secure, and SameSite cookies, output encoding and CSP against XSS, anti-CSRF tokens, and rotating the session ID whenever privileges change.