Secure Logout and Session Termination

Logout is the control users trust most and the one implementations get wrong most often: a button that clears a cookie while the session record, the refresh token and the identity provider’s own sign-on cookie all stay alive. This page is part of the Modern Authentication Fundamentals guide, and it covers what a complete termination has to do — locally, upstream, across tabs, and across devices — plus how to prove it worked.

Prerequisites

  • A server-side session you can delete, as described in managing session state in Redis, or an equivalent store.
  • Knowledge of which upstream credentials exist for a session — a refresh token, a provider sign-on cookie, or both, per the token revocation guide.
  • CSRF protection on state-changing routes, because logout is one of them.

What “Logged Out” Actually Has to Mean

The word covers four different pieces of state, and clearing one of them is what produces the classic bug: the user clicks sign out, clicks sign in, and is instantly back in the application without being asked for anything.

The four pieces of state a logout must address The browser cookie, the server-side session record, any upstream refresh token, and the identity provider's own sign-on cookie each survive independently, so clearing only the cookie leaves three live paths back into the account. Browser cookie easy to clear least important Session record delete it this is the real one Refresh token revoke upstream or it outlives logout Provider cookie end-session endpoint else sign-in is silent Clearing only the first is the bug users describe as "logout does not work" — they sign out, sign in again, and are admitted without a prompt.
Four independent lifetimes. A logout that does not name all four in code is only ending one of them.

Step 1 — Make Logout a Protected POST

Logout changes state, so it needs the same protection as any other mutation: a POST with an anti-CSRF token, never a bare GET. An endpoint reachable by GET /logout can be triggered by an image tag on any other site, and while forcing a sign-out is a nuisance rather than a breach, it is a nuisance an attacker can inflict on every user of your product at will — and in a shared-computer context it can be used to push a victim into re-authenticating on a phishing page.

Make the handler idempotent. A double submit, a retry after a timeout, or a user clicking twice must all produce the same result: session gone, cookie cleared, redirect issued.

Order matters. Delete the server-side session first, then clear the cookie: if the request fails between the two steps, you are left with a cookie pointing at nothing, which is harmless. Reversing the order leaves a live session whose identifier the browser has forgotten but an attacker who captured it has not.

export async function logout(req: Request, res: Response) {
  const sid = req.cookies["__Host-sid"];
  if (sid) {
    const session = await sessions.get(sid);
    await sessions.destroy(sid);                 // authoritative step
    if (session) {
      await sessions.removeFromUserIndex(session.userId, sid);
      await revokeUpstream(session).catch(logAndQueueRetry);   // never blocks the logout
    }
  }
  // Same name, path and prefix as the cookie that was set, or the browser keeps the original.
  res.cookie("__Host-sid", "", { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 0 });
  res.redirect(303, "/goodbye");
}

Clearing a cookie means re-sending it with an identical name, Path, Domain and prefix plus Max-Age=0. Any mismatch and the browser keeps the original — which is the source of most “logout works in one browser but not another” reports.

Step 3 — Revoke Upstream, and Do Not Let It Block

If the session was established through an identity provider, the refresh token held server-side outlives your session record unless you revoke it, and the provider’s own sign-on cookie outlives both unless you send the user through the end-session endpoint.

Ordering of logout steps and their failure handling Destroying the session and clearing the cookie happen first and must always succeed; revoking upstream tokens is attempted next and queued for retry on failure; the redirect to the provider's end-session endpoint happens last. 1 · destroy record must succeed 2 · clear cookie exact attributes 3 · revoke upstream retry on failure 4 · end session at the provider A provider timeout must not leave the user signed in — steps 1 and 2 have already happened.
The local steps are synchronous and mandatory; the upstream steps are best-effort with a retry queue, so a slow provider cannot keep someone signed in.

The detailed mechanics — which token to revoke, what the endpoint returns, and why an unknown token still gets a 200 — are covered in revoking tokens on logout in a BFF.

Step 4 — Propagate to Other Tabs and Devices

A user with three tabs open expects all three to notice. The mechanism is a broadcast within the browser plus a server-side check that the others will hit anyway.

Use the BroadcastChannel API — or a storage event as a fallback — to tell other tabs that the session ended, so they can clear their in-memory state and redirect rather than showing stale data until the next request fails. This is a user-experience improvement, not a security control: the security comes from the server rejecting the destroyed session on the next request, which happens regardless of what the tabs believe.

Signing out other devices is a different operation and deserves its own control in the account settings. Read the user’s session index, delete every record, bump the session version so anything cached elsewhere becomes stale, and revoke every refresh-token family belonging to the user. Show the result — “4 sessions ended” — because the point of the feature is reassurance.

Step 5 — Audit Every Termination

Every ended session should produce an event recording who ended it, which session, from where, and why: user action, idle timeout, absolute expiry, administrative revocation, or automatic termination after a password change. That last category matters — a password change, a factor removal, or a suspected compromise should end existing sessions, and the audit trail is what proves it happened.

Reasons a session ends and what each should trigger User logout ends one session, idle and absolute timeouts end it silently, a password change or factor removal ends every session for that user, and administrative revocation ends them and notifies the account owner. User clicks sign out this session only, plus upstream revocation Idle or absolute timeout silent; the next request is anonymous Password or factor change every session for that user, and notify them Administrative revocation all sessions, audited with the actor recorded
The third row is the one most often missed: changing a password while old sessions keep working defeats the purpose of changing it.

Validating That Logout Works

Four assertions, run automatically, cover almost everything that goes wrong. After logout, the response must clear the cookie with a matching name and path. Replaying the old cookie must return unauthenticated rather than a fresh session. The refresh token must be rejected by the provider. And visiting the login page must prompt for credentials rather than signing the user straight back in — that last one is what catches a missing end-session call, and it is invisible to every other test.

Add a manual check on a real browser for the multi-tab case, because it depends on browser behaviour rather than on your server: log in, open two tabs, sign out in one, and confirm the other stops working rather than continuing to render stale authenticated content.

Frequently Asked Questions

Why does signing in immediately after logout not ask for credentials?

Because the identity provider still has its own sign-on cookie. Your application ended its session, but the provider considers the user authenticated, so the next authorization request is answered silently. Fix it by redirecting to the provider’s end-session endpoint with an id_token_hint after your local logout, and confirm with a test that the login page prompts afterwards.

Should logout be a GET or a POST?

POST, with an anti-CSRF token. A GET endpoint can be triggered by any third-party page through an image or link prefetch, which lets an attacker sign your users out at will and can be used to push someone into re-authenticating on a page they do not control. If you need a link in the interface, make it a form styled as a link rather than an anchor.

Is clearing the cookie enough if the session record has a short TTL?

No. The TTL bounds the damage but does not end it: anyone holding a copy of the identifier — from a shared machine, a proxy log, or an earlier capture — can keep using it until it expires, and using it may refresh the sliding window. Deleting the record is the authoritative act; clearing the cookie is housekeeping for the browser that just asked to leave.

What should happen to other tabs when one signs out?

They should notice quickly and clear their state, which a BroadcastChannel message achieves in the same browser. Do not rely on it for security: a tab that ignores the message still fails on its next request because the server no longer knows the session. Treat cross-tab propagation as a way to avoid showing stale authenticated content, not as the mechanism that ends access.

Should a password change end existing sessions?

Yes, except optionally the one performing the change. The usual reason someone changes a password is that they suspect it is compromised, and leaving other sessions alive defeats the purpose. End them, revoke the associated refresh-token families, notify the user that it happened, and record it in the audit trail — that notification is also how a legitimate user learns their account was accessed elsewhere.

Timeouts Are Terminations Too

Most sessions do not end with a click; they expire. Treating expiry as part of the same design rather than as a separate mechanism keeps the behaviour predictable and the audit trail complete.

Run two independent clocks. The idle timeout ends a session that has stopped being used, and it exists mainly to protect the user who walked away from a shared machine — fifteen to thirty minutes for administrative or financial contexts, a few hours for ordinary applications. The absolute timeout runs from authentication and caps the total life of any session regardless of activity, which is the control that actually bounds a stolen credential. Sliding expiry alone lets an attacker keep a session alive indefinitely simply by using it, so the absolute cap is not optional.

Make expiry visible rather than abrupt. A session that vanishes mid-form is remembered as a bug; one that warns a minute before, offers to extend, and preserves the in-progress work is remembered as a security feature. Implement the extension as a real request that refreshes the idle window server-side, not as a client-side timer that keeps the page alive while the session dies underneath it.

Decide explicitly what an expired session does to an in-flight request. Returning a specific, machine-readable error rather than a generic 403 lets the frontend re-authenticate in place and replay the action, which is the difference between a smooth re-login and a lost draft.

Ending Sessions on Security Events

Several events should terminate sessions even though nobody pressed a button, and each needs a decision recorded in code rather than left to habit.

A password change should end every other session for that user, because the usual reason for changing a password is suspicion that it is known. Removing or replacing a second factor should do the same, since the point of the change is to invalidate the previous authentication path. A detected refresh-token reuse, described in detecting refresh token reuse with rotation, should revoke the whole family and end the associated sessions, because you cannot tell the attacker from the legitimate user at that moment. An administrator disabling an account should end everything immediately and be able to prove it did.

In every one of those cases the user should be told, through a channel that does not depend on the credential just invalidated. A notification email that says “your password was changed and 3 sessions were signed out” is both a courtesy and a detection mechanism: if the user did not do it, that message is how you find out.

Making Termination Provable

Two questions get asked after any incident: when did this session end, and what could it do before it did? Both are answerable only if terminations are recorded as events with the same rigour as logins.

Record the session identifier — or a hash of it, so the log itself is not a credential store — the user, the reason, the actor when it was not the user, the source address, and the timestamp. Keep those events for as long as your longest regulatory obligation, in storage the application can append to but not rewrite, alongside the permission-change audit trail so a single query can reconstruct what an account was able to do at a point in time.

Then measure the thing that actually matters: the interval between a termination and the last successful request made with that credential. In a correct implementation it is zero. Anything else means a cache, a replica, or a resource server that had not caught up — and that number, not the configured timeout, is your real revocation latency.

Designing the Sign-Out Experience

A logout that is technically complete can still fail the user, and the design details are cheap to get right.

Give the action a clear, predictable location and a single confirmation state. Users on shared machines look for it under pressure, and a sign-out buried in a nested menu leads to abandoned sessions on public computers — which is the exact scenario the whole mechanism exists for. Where the product involves shared devices at all, consider an explicit “I am on a shared computer” option at login that shortens the absolute timeout and skips any remember-me behaviour.

Say what happened. “You are signed out on this device” and “You are signed out on all 4 devices” are different statements, and users need to know which one they got. When the action ends other sessions — after a password change, for example — say so, with the count, so the outcome is not a surprise the next time they pick up their phone.

Make the return state safe. Redirect to a page that does not require authentication and does not leak the previous context, clear any client-side caches your framework maintains, and make sure the browser’s back button cannot render a cached authenticated page: authenticated responses need Cache-Control: private, no-store, or the previous screen is one keypress away on a shared machine even though the session is gone.

Finally, keep the device list honest. If your product shows active sessions, show the same information logout affects — device, approximate location, last activity — and make each entry individually revocable. A list that cannot be acted on is decoration; one that can is the feature users reach for the moment they suspect something is wrong.

Common Mistakes Worth Checking For

Five failures account for most broken logouts, and each is quick to test. The cookie is cleared with different attributes than it was set with, so the browser keeps the original — check name, path, domain and prefix character for character. The session record is left in place because the handler only touched the cookie, which means a captured identifier still works. The upstream refresh token is never revoked, so a stolen copy continues minting access tokens long after the user believes they left. The provider’s sign-on cookie is untouched, so the next login is silent and users reasonably conclude that sign-out did nothing. And the endpoint accepts a GET, letting any third-party page sign users out on demand.

Write one test per failure. They are unglamorous, they take an afternoon, and together they cover the entire surface of a control that users assume already works.

Add one more to the list if your product has a mobile client: an application that stores a session identifier in its own keychain must clear it locally as well as calling the server, or a reinstall-free “sign out” leaves the credential on the device. The server-side deletion is what ends access, but a stale local credential produces confusing error states on the next launch, and users read those as the application being broken rather than as a security boundary doing exactly what it was designed to do.

Whatever the client, the rule is the same: the server-side record is the credential, and everything on the device is a copy that should be cleared for hygiene rather than trusted as the boundary.