4Station 4 of 6: Session
The logout that did not log out
A logout that only deletes the cookie in the browser leaves the session alive on the server. Anyone who copied the cookie before logout can keep using it.
$ uv run python -m wire run 5$ uv run python -m wire check 5Goal#
Fix a logout implementation that deletes the cookie in the browser but never invalidates the session on the server, allowing stolen session cookies to remain valid indefinitely.
The idea#
What logout means
When a user signs out, two things must happen:
- The browser must delete the session cookie.
- The server must invalidate the session so the cookie is worthless.
Most logout implementations do only the first. The server sends Set-Cookie: sid=; Path=/; Max-Age=0, which tells the browser to delete the cookie immediately. The browser obeys. Future requests from that browser have no cookie, so the server treats them as not signed in.
But the session is not just the cookie. It is the server-side state that the cookie unlocks. If the server never forgets the session, anyone who copied the cookie before the user signed out can keep using it.
Why server-side invalidation is required
Session cookies are copied all the time:
- On shared computers where another user can inspect the browser's cookies.
- In proxy logs that record every
Cookieheader. - By browser extensions that have access to cookies.
- Over unencrypted connections if the cookie lacks
Secure. - Through XSS if the cookie lacks
HttpOnly. - In memory dumps, debug logs, or crash reports.
The user cannot undo any of these. The only way to make a stolen cookie worthless is to remove the session from the server's session store. A logout that only tells the browser to delete the cookie is a request, not an enforcement. The browser might comply, or it might not (if the user has multiple tabs, some browsers keep the cookie until all tabs close). Even if the browser deletes it, copies of the cookie remain valid until the server explicitly invalidates them.
How session invalidation works
When the user signs out, the logout handler must:
- Receive the session id from the
Cookieheader. - Remove that session from the session store (in-memory dict, Redis, database, or cache).
- Send
Set-CookiewithMax-Age=0to tell the browser to delete its copy.
After step 2, the session id no longer maps to anything. The next request with that cookie, from any device or attacker, returns 401 Unauthorized.
def end(self, session_id: str | None) -> None:
self.by_id.pop(session_id or "", None)
This is a one-line fix, but it is the line that makes logout work.
What people get wrong
Assuming the browser is the source of truth. The browser's cookie jar is a cache of the session id, not the session itself. The server decides what a session id means. If the server never forgets the session, the browser's opinion does not matter.
Logout that expires the cookie but does not delete the session. Some implementations set Max-Age=0 without removing the session from the store. The browser deletes the cookie, but the session lives forever. The fix is to always remove the session from the store on logout.
Multi-device confusion. A user signs in on a laptop and a phone. They sign out on the laptop. Should the phone be signed out too? Most consumer apps sign out one device at a time (the one that requested logout), with a separate "sign out everywhere" option. Enterprise apps often sign out all devices at once. Either way, the session that was used to request logout must be invalidated. If you keep per-device sessions, invalidate the one that signed out. If you keep per-user sessions, invalidate all of them.
How production systems handle sessions
Session stores. Sessions live in memory (a dict or cache in the process), in Redis or Memcached (a shared cache), or in a database (PostgreSQL, MySQL, DynamoDB). When the user signs out, you must remove the session from wherever it lives. For in-memory stores, delete the key from the dict. For Redis, run DEL session:{id}. For databases, delete the row or mark it as revoked.
JWT without server state. JSON Web Tokens are self-contained: they carry the user identity and claims, signed by the server. There is no session store. You cannot revoke a JWT without server-side state. If you issue JWTs with long lifetimes (hours or days) and no revocation list, logout does not work. The token remains valid until it expires. The fix is to keep a revocation list (a set of revoked token IDs with expiry matching the token lifetime) and check it on every request, or use short-lived access tokens with a separate refresh token that can be revoked.
Session expiry. Sessions should expire even if the user never signs out. Set a Max-Age on the cookie (8 hours is typical) and store an expiry timestamp with the session. On every request, check whether the session has expired and delete it if so. This limits the window for stolen sessions to the session lifetime. Idle sessions (no activity for 15 to 30 minutes) should also expire. Use a sliding expiry: update the expiry timestamp on every request, so active users stay signed in.
Sign out everywhere. Many apps provide a button to revoke all sessions for the user. This is useful after a password change, after suspicious activity, or when the user loses a device. To implement it, keep a per-user list of active session ids, or query the session store for all sessions with that user id, and delete them all.
Logout endpoints. Most apps have POST /logout. Some also have GET /logout, which is a CSRF vulnerability: an attacker can sign the user out by embedding <img src="https://yourapp.test/logout"> on their site. Use POST for logout, or require a CSRF token on GET.
The case#
Run the case to see the weakness:
uv run python -m wire run 5
case 05: The logout that did not log out
A user signs in on a laptop and a phone, then signs out on the laptop.
Before the user signed out, someone copied the laptop's session cookie.
GET /me from the laptop 401
GET /me from the copied cookie 200
GET /me from the phone 200
The laptop signed out, and GET /me returns 401. The phone is still signed in, which is correct: logging out on one device should not sign out all devices unless you want that. But the copied cookie still works, returning 200 with the user's data. The session was never invalidated.
Reasoning towards the fix#
Open lab/cases/case05_logout.py. The Sessions class tracks sessions in a dictionary. The start method adds a session, user looks it up, and end is supposed to remove it.
As written, end does nothing. The comment says "the cookie is deleted in the browser, which is the same thing." It is not the same thing.
The logout handler calls sessions.end(session_id), then returns a response with Set-Cookie: sid=; Path=/; Max-Age=0. The Max-Age=0 tells the browser to delete the cookie immediately. But the session itself is still in sessions.by_id, so anyone with the session id can keep using it.
To fix it, implement end:
def end(self, session_id: str | None) -> None:
self.by_id.pop(session_id or "", None)
This removes the session from the store. The next request with that session id will fail, even if the cookie was copied before logout.
The check script creates three clients: a laptop, a phone, and a copier. The laptop and phone both sign in, so they get different session ids. The copier copies the laptop's cookie. Then the laptop signs out. The laptop's session must be invalidated, so GET /me from the laptop and the copier both return 401. The phone's session is independent, so it continues to work.
Check your fix#
Once you have made the changes, check them:
uv run python -m wire check 5
As shipped, the check output is:
PASS the laptop is signed out (401)
FAIL a copy of the old cookie no longer works (200)
PASS the phone, a different session, stays signed in (200)
not yet. Try: uv run python -m wire hint 5
When fixed, all lines show PASS. Signing out on the laptop invalidates that session, but the phone's session remains active.
Try it on your own machine#
Test your own apps. Sign in, copy the session cookie from the browser's developer tools, sign out, and try using the copied cookie with curl:
curl -H "Cookie: your_session_cookie_here" https://yourapp.example/api/me
If it still returns your data, logout is not removing the session.
In production#
Logout must invalidate the session on the server, not just tell the browser to forget it. Depending on how you store sessions, this means:
- In-memory store: remove the session from the dictionary or cache.
- Redis or Memcached: delete the key.
- Database: mark the session as revoked, or delete the row.
- JWT without server state: add the token's
jtito a revocation list with expiry matching the token's lifetime. JWTs are not sessions, and you cannot revoke them without server-side state.
For multi-device sessions, decide whether logout signs out all devices or just the current one. Most consumer apps sign out one device at a time, with a separate "sign out everywhere" option. Enterprise apps often sign out all devices at once. Either way, the session must be removed from the store.
Handle session lifetime separately from logout:
- Set a
Max-Ageon the cookie so sessions expire even if the user never signs out. - Store an expiry timestamp with the session and check it on every request.
- For long-lived sessions, use a sliding expiry: extend the session on every request, so active users stay signed in.
- Provide a separate "sign out everywhere" action that revokes all sessions for the user.
A logout endpoint is not enough. You also need:
- Automatic expiry of idle sessions.
- A way for users to see and revoke active sessions.
- A way for admins to forcibly revoke a user's sessions (for account compromise, or after a password reset).
If you let users stay signed in for weeks, session theft becomes a long-term persistence mechanism. Tune session lifetimes to your threat model, and make logout actually work.
Key points#
- Logout must invalidate the session on the server, not just delete the cookie in the browser.
- A session is server-side state. If you do not delete it, it lives until the server restarts.
- Test by copying the session cookie before logout, then trying to use it after. It must not work.
- Multi-device sessions require per-session tracking. Logout signs out one device unless you want "sign out everywhere."
- Set
Max-Ageon the cookie so sessions expire even if the user never signs out.