Why a Session Cookie Still Works After Logout, and the Real Fix
Here is a test that takes two minutes and fails on a surprising number of apps. Log in, copy the session cookie value, click "Log out", then replay the copied cookie with curl. If you get your account page back, your logout button was decoration.
The cause is nearly always the same: logout told the browser to forget the cookie, but nothing told the server to forget the session.
Deleting the cookie only cleans up your own browser
A typical broken logout handler looks like this:
func logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
That header is a polite request to the browser. A browser that obeys it drops the cookie. But anyone who already holds a copy of the value is unaffected, because the server never changes its mind about whether that value is valid.
Who holds a copy? Malware on the machine, a proxy that logged headers, a browser extension, a shared computer where the history was synced, or an attacker who got the value from a log file. Logout is exactly when the user expects all of that to stop mattering.
Two designs, two different problems
How you fix it depends on what the cookie actually is.
- Opaque session ID: a random string that is a key into a server-side store. Revocation is easy; you delete the row.
- Self-contained token: a signed blob (a JWT, or a signed cookie) carrying the user ID and an expiry. The server validates the signature and trusts the contents. Revocation is the awkward one.
With a self-contained token, there is nothing to delete. The signature is valid and the expiry has not passed, so the server has no reason to refuse it. Clearing the cookie changes nothing about that arithmetic.
Fix one: delete the session on the server
For opaque IDs, logout must remove the server-side record first and clear the cookie second. Here is a minimal in-memory store; swap it for Redis or a database table in real life.
type Store struct {
mu sync.Mutex
sessions map[string]string // id -> user
}
func (s *Store) Destroy(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
func (s *Store) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("session"); err == nil {
s.Destroy(c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: true,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
Note that the lookup on every request must treat a missing ID as "not logged in". If it falls back to some default or cached identity, you have rebuilt the original bug.
Quick detour: why does the replay sometimes still work after this?
Hang on, there is a classic trap here. If you put a cache in front of the session store (an in-process map, a CDN, a read replica with lag), the old value can live on in the cache after the primary row is gone.
That is not a logout bug exactly, but the user experiences it as one. Either invalidate the cache entry in the same step, or give cache entries a lifetime short enough that you can state it plainly: "revoked within 30 seconds". Then at least you know the number.
Fix two: make signed tokens checkable against server state
If you are stuck with signed tokens, you need some server state, or revocation is impossible. The cheapest scheme is a per-user counter. Put it inside the token when you issue it, and compare on every request.
type Claims struct {
UserID string
Version int64 // copy of users.session_version at issue time
Expires time.Time
}
func valid(c Claims, current int64) bool {
return time.Now().Before(c.Expires) && c.Version == current
}
Logout (or "log out everywhere", or a password change) then runs UPDATE users SET session_version = session_version + 1. Every token issued before that is now rejected, including copies. The cost is one lookup per request, which is the same price as an opaque session store. That is rather the point: you cannot have instant revocation and zero server state.
If you only want to kill one session rather than all of them, keep a small deny list of token IDs that expire when the token would have expired anyway. The list stays small because entries age out.
The other half: shorten the damage window
Revocation handles the case where you know a session should die. Plenty of the time you do not know. So also set an absolute lifetime and an idle timeout on the server side, not just a Max-Age on the cookie, which the client controls and the attacker can ignore.
Also rotate the session ID when privilege changes: at login, and when someone elevates to an admin view. Rotating at login stops session fixation, where an attacker plants a known ID before the victim authenticates. The OWASP Session Management Cheat Sheet covers the same ground and is worth a skim.
Verify it, because this regresses quietly
Write the two-minute test as an automated one. It is short:
- Log in and capture the
Set-Cookievalue. - Call an authenticated endpoint with it; expect 200.
- Call logout with the same cookie.
- Call the authenticated endpoint again with the old value; expect 401 or a redirect to login.
Step four is the one people forget, because a browser-driven test never replays the old cookie; it has already deleted it. Use a plain HTTP client for this test, so nothing is cleaning up on your behalf.
One caveat: none of this helps if the attacker is acting in the live session before logout. Revocation ends a session. It does not undo what happened during it.