SyncTrix logoSyncTrix
All articles
Security11 min read

Session management: the logout button that does not actually log anyone out

Stateless JWTs made sessions cheap and revocation impossible. Here's what breaks at logout, at password reset, and at the moment you actually need to kick someone out.

By Marcus Hale
Session management: the logout button that does not actually log anyone out

Most teams discover their session design is broken during an incident, when someone asks how quickly a compromised account can be locked out and the honest answer is 'in about fifteen minutes, when the access token expires'. The root cause is almost always the same trade: a stateless token was chosen because it avoided a database lookup, and revocation was quietly given up in exchange. That trade is defensible, but only if you made it deliberately.

01What logout actually has to do

Clearing a cookie in the browser is not logout. It removes the client's copy of the credential and does nothing to the credential itself. If an attacker already copied the token - from a shared machine, from a proxy log, from an XSS payload that ran an hour ago - the token keeps working for its full lifetime and your logout endpoint returned 200 the whole time.

Real logout invalidates server-side state. With an opaque session identifier that is a single row delete and the session is dead on the next request. With a self-contained token there is nothing to delete, because the entire point of the design was that the server does not need to look anything up. This is the fork in the road, and everything else in session design follows from which side of it you are on.

02Stateless tokens and the revocation gap

A signed token is verified by checking a signature and an expiry claim. Nothing in that check consults your system's current opinion of the user, so a token issued before a password reset, a role downgrade, a suspension or an offboarding remains valid until it expires. The window between 'we decided to revoke' and 'the token stops working' is the revocation gap, and it is exactly the token lifetime.

Teams close this gap by shrinking access token lifetimes to minutes and pairing them with a long-lived refresh token that is stored server-side and can be revoked. This works, but be honest about what it means: you have reintroduced a database lookup, just on the refresh path rather than every request. That is usually the right answer, because refreshes are two orders of magnitude rarer than API calls.

  • Access tokens: 5-15 minutes, stateless, no revocation check.
  • Refresh tokens: server-side record, revocable, rotated on every use.
  • Keep a per-user token version or `not_before` timestamp; bump it on password change and force re-authentication everywhere.
  • Maintain a short deny-list keyed by token ID for the emergency case - it only needs to hold entries for the access token lifetime.

03Refresh token rotation and reuse detection

A refresh token that can be used repeatedly is a long-lived credential, which is the thing you were trying to avoid. Rotation fixes this: each use issues a new refresh token and invalidates the old one. The old token is now a tripwire.

Reuse detection is the payoff. If a rotated-out refresh token is presented again, either the legitimate client replayed it after a network failure, or an attacker stole it and one of the two parties is now using a stale copy. You cannot tell which, so the correct response is to revoke the entire token family and force re-authentication. This is one of the few mechanisms that detects theft rather than merely limiting its duration.

04Where the token lives matters more than its format

The storage decision drives the attack surface, and it is where most implementations go wrong. localStorage is readable by any JavaScript on the page, which means any XSS is total session compromise with no further work required. An HttpOnly cookie is not readable by script, so the same XSS can make requests as the user but cannot exfiltrate a credential that outlives the page.

Cookies introduce cross-site request forgery in exchange, which is a solved problem: SameSite plus an anti-CSRF token on state-changing requests. Trading an unsolved problem for a solved one is a good trade, and 'JWTs mean you do not need cookies' is a misreading - the token format and the transport mechanism are independent choices.

StorageXSS exposureCSRF exposureVerdict
localStorage / sessionStorageFull - token exfiltrated by any scriptNoneAvoid for session credentials
Non-HttpOnly cookieFull - readable via document.cookieYesWorst of both; no reason to use
HttpOnly, Secure, SameSite cookieLimited to in-page request forgeryMitigated by SameSite + tokenDefault choice for browser clients
In-memory only (JS variable)Readable by script but dies on reloadNoneGood for SPAs paired with a cookie refresh
Session credential storage

05Fixation, binding and the shared-device case

Session fixation is old and still present: if the identifier issued before authentication is the same one used after, an attacker who plants a known identifier is logged in as whoever authenticates next. The fix is one line - regenerate the session identifier on every privilege transition, meaning login, step-up authentication and impersonation - and it is skipped surprisingly often in custom auth code.

Binding sessions to client properties helps but needs care. Binding to the IP address breaks mobile users the moment they move between cellular and wifi, and support will ask you to disable it within a week. Binding to a coarse device fingerprint or to the TLS session is more durable. Treat any binding mismatch as a signal for step-up authentication rather than an immediate hard failure, or you will train users to expect random logouts.

06What to verify this week

Run four tests against your own system. Replay a token after logout. Replay a token after a password reset. Change a user's role and see how long the old permissions survive. Delete a user and time how long their token keeps working. Each of these takes ten minutes and each one has an answer your security questionnaire already claims to know.

Then add session visibility for users - a list of active sessions with device, location and last-seen time, and a control to terminate each one. It is a modest feature that makes account takeover recoverable by the user rather than by your support team, and building it forces you to have a real server-side session record, which is the underlying fix anyway.

Topics

session management best practicesjwt vs session cookiestoken revocationrefresh token rotationsamesite cookie attributelogout not working security

Marcus Hale

Lead Architect · SyncTrix

Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.

Building something like this?

SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.