What logging out should actually do
A user signs out. The interface returns to the login screen. The token they were using still works. This is one of the most common findings in application testing, and it is rarely an oversight: it is what happens when a stateless design meets an action that requires state.
Key takeaways
- Signing out should end the session on the server. Clearing it from the browser only ends the appearance of one.
- Stateless tokens cannot be withdrawn by design. If you issue them, you need a deliberate answer for logout, not the default one.
- A short access token with a revocable refresh token gives you both properties, and is the usual answer.
- Logout has to reach every credential the session created, including any long-lived remember-me token.
- On a shared or mobile device, local cached data survives sign-out unless somebody clears it explicitly.
What actually goes wrong
The user presses sign out. The application deletes the token from local storage or expires the cookie, redirects to the login page, and reports success. Nothing was communicated to the server, or something was and the server did not act on it.
Anybody holding a copy of that token can keep using it until it expires on its own. That includes a token captured earlier, one left in a log, one on a shared machine, and one on a device the user has since sold.
It is easy to miss in testing because the interface behaves correctly. Signing out looks like it worked, and it did, from the browser point of view. Finding it means replaying an old token after signing out, which is a deliberate act nobody performs by accident.
Why stateless tokens cause this
A signed, self-contained token is attractive because verifying it needs no lookup. That property is the point, and it is also exactly why you cannot cancel one. The server does not consult a list, so there is no list on which to mark it invalid.
Teams reach this conclusion in the wrong order. The token design is chosen for performance, logout is implemented as a client-side delete because that is all the design allows, and the gap is only noticed when somebody tests for it or an account is compromised and cannot be cut off.
The question worth asking during design
If an account is compromised right now, how do we end its access, and how long does that take to take effect? A design where the honest answer is "when the token expires" is a design decision, and it should be made deliberately rather than discovered later.
What to invalidate, and where
The usual answer keeps the performance benefit and restores control: a short-lived access token that is never checked against a store, paired with a long-lived refresh token that is.
- Keep access tokens short, in minutes rather than days. The exposure window after logout is then bounded by that lifetime.
- Store refresh tokens server side and delete them on logout, so the session cannot be renewed.
- Rotate the refresh token on each use, and treat reuse of an old one as theft: end the whole session family.
- Keep a revocation list for access tokens if minutes is still too long for your risk, accepting the lookup it costs.
- End every credential the session created, including remember-me tokens, device tokens and any active socket connection.
- Offer a way to end sessions on other devices, and use it yourself when a password changes.
A password change or reset should invalidate everything outstanding. If it does not, a user who changes their password because they think somebody is in their account has not removed them.
Expiry, idle timeouts and the other half
The companion finding is a session that never ends on its own. A token valid for a year is a credential with a year of exposure, and most users never sign out at all, so expiry is the control that actually runs.
- An absolute lifetime, after which re-authentication is required regardless of activity.
- An idle timeout, so an abandoned session on a shared machine closes itself.
- Re-authentication before genuinely sensitive actions, independent of session age.
- A new session identifier issued at login, so a value set before authentication cannot be reused after it.
The right numbers depend on what the application does. A banking session and a reading application should not have the same timeout, and copying one from the other is how a control ends up either useless or infuriating.
Testing it yourself
Like the rate limiting check, this is something a developer can verify in a few minutes and should not be paying to discover.
- Sign in and capture the token or cookie your client is sending.
- Sign out through the interface.
- Replay a request with the captured value, from a separate client.
- If it succeeds, logout did not reach the server.
Then repeat it after a password change, which is the case that matters more. And on mobile, check what is still readable in local storage once the user has signed out, which is covered in the mobile controls guide.