Clearing Session Cookies That Refuse to Expire

The logout handler runs, the response looks right, and the cookie is still in the browser. This page is part of the secure logout and session termination guide and works through why a deletion is ignored, how to find which of the several possible causes applies, and what the correct Set-Cookie looks like.

Root Cause: Deletion Is a Match, Not a Command

There is no “delete cookie” instruction in HTTP. A browser removes a cookie when it receives a new one with the same identity and an expiry in the past — and identity means the name plus the Path plus the Domain plus any name prefix. Miss one of those and the browser stores a second cookie that expires immediately, leaving the original untouched.

Why a deletion attempt can create a second cookie instead A cookie set with Path equals slash and no domain is only deleted by a response matching those attributes; a deletion sent with a different path creates a separate expired cookie and leaves the original in place. original: Set-Cookie: __Host-sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax Mismatched deletion Set-Cookie: __Host-sid=; Path=/app second cookie created; original survives Matching deletion Set-Cookie: __Host-sid=; Path=/; Max-Age=0 same identity; cookie removed
The browser is matching, not obeying. Any attribute that participates in identity has to be reproduced exactly.

The Correct Deletion

Send the same name, the same Path, the same Domain (or, for a __Host- cookie, no Domain at all), plus Max-Age=0. Keep Secure and HttpOnly as they were: they do not participate in matching, but a __Host- prefixed cookie is rejected outright without Secure, so the deletion would be discarded before it could match anything.

// Deleting must mirror how the cookie was set.
res.cookie("__Host-sid", "", {
  httpOnly: true,
  secure: true,        // required for the __Host- prefix to be accepted at all
  sameSite: "lax",
  path: "/",           // identical to the Path used when setting it
  maxAge: 0,           // expires immediately
  // NOTE: no `domain` — a __Host- cookie must not carry one, when set or when cleared
});

Keep the setting and clearing in one module with a shared options object, so the two cannot drift. Most of these bugs are introduced by a later change to the set path — adding a domain for a new subdomain, moving to a prefixed name — that nobody mirrored in the logout handler.

Diagnosing Which Cause Applies

Open the browser’s application panel and look at the cookie list before and after logout. Four patterns identify the four common causes.

Diagnosing a cookie that survives logout Two cookies with the same name indicate a path or domain mismatch, an unchanged single cookie indicates the header was never sent or was stripped, a rejected deletion indicates a prefix violation, and a cookie that returns immediately indicates another request re-setting it. Two cookies, same name path or domain mismatch — compare both One cookie, unchanged header never sent, or stripped by a proxy Deletion shown as rejected prefix violated — missing Secure, or a Domain Cookie reappears at once a parallel request re-set it after logout
Chrome's response Cookies tab reports rejections with a reason, which turns the third row from guesswork into a one-line answer.

The last row is the subtlest. A single-page application often has in-flight requests when logout fires, and if any of them touch a path that refreshes the session cookie, the response arrives after the deletion and re-creates it. Cancel outstanding requests before logging out, or make session refresh conditional on the session still existing — the server deleted the record first, so a refresh for a destroyed session should be a no-op rather than a new cookie.

Proxies deserve a specific check. A reverse proxy with proxy_cookie_path or a rewriting rule can alter the Path on the way out, so the header your application emits and the one the browser receives are different strings. Capture the response at the edge, not in the application log, before concluding the code is wrong.

Verifying the Fix

Add an assertion to your logout test that reads the Set-Cookie header and checks its full attribute set against the one issued at login, rather than merely asserting a redirect. Then add the behavioural check: replay the old cookie value against an authenticated endpoint and require an unauthenticated response.

That second assertion matters more than the first. The cookie is housekeeping; the session record is the credential. A logout that fails to clear the cookie but destroys the record is a cosmetic bug. One that clears the cookie and leaves the record alive looks perfect in the browser and is a real vulnerability, because anyone holding a copy of the identifier can keep using it.

Run both against every environment. Cookie behaviour differs with scheme, domain and prefix, so a test that passes on a local HTTP origin proves very little about a production HTTPS one with a __Host- prefix.

Preventing the Whole Class of Bug

The reliable fix is structural rather than a careful deletion handler. Define the cookie’s options exactly once, in one module, and export two functions — one that sets it and one that clears it — both reading the same options object. Every call site uses those functions and nothing constructs a Set-Cookie by hand. A change to the name, the path or the prefix then updates both directions simultaneously, which removes the drift that causes almost every occurrence of this problem.

Pin the behaviour with a test that reads the real headers. Log in against a running instance, capture the Set-Cookie from the login response, log out, capture it again, and assert that the identity attributes match and that the second carries a zero max-age. That assertion fails the moment someone adds a domain on one side only, which is exactly the change that would otherwise ship quietly.

Add the behavioural assertion alongside it: replay the pre-logout cookie value and require an unauthenticated response. That one covers the case where the cookie clears perfectly and the session record survives — a far more serious bug that a cookie-focused test would happily pass.

Finally, keep an eye on the proxy layer. Any rule that rewrites cookie paths or domains — added for a migration, a path-based route, or a legacy application sharing the origin — changes the identity of the cookie in transit and therefore breaks deletion for everything behind it. Treat such rules as security-relevant configuration, review them like code, and re-run the header assertions against staging after any change to the edge.

Attributes That Participate in Identity

Which cookie attributes must match for a deletion to work Name, path, domain and any name prefix determine cookie identity and must be reproduced exactly, while Secure, HttpOnly and SameSite do not affect matching but may cause the deletion itself to be rejected. Must match exactly name · Path · Domain · prefix get one wrong and you create a second cookie Does not affect matching Secure · HttpOnly · SameSite but a missing Secure voids a __Host- deletion
The right-hand box still matters: a deletion rejected for violating a prefix never gets as far as matching anything.

Two adjacent problems present almost identically and have different causes. A cookie that disappears on its own before the user logs out is usually a browser rejection at set time — a prefix violation or a SameSite=None without Secure — rather than anything to do with logout. And a session that persists across what looks like a successful logout in a private window, but not a normal one, usually means the record survived and the cookie was merely re-sent from a different store.

A third variant appears in applications served from several origins, where the user appears logged out on one host and still logged in on another. That is not a deletion failure at all — it is two separate cookies on two separate origins, and the fix is to end the session record centrally rather than to chase cookies per host.

Both of the first two are diagnosed the same way: compare what the server sent with what the browser kept, using the network panel rather than the application log, and then check whether the server-side record still exists. Those two facts together identify every variant of this problem.

Frequently Asked Questions

Why does the same logout code work in one browser and not another?

Usually because the browsers received different cookies in the first place. A cookie rejected at set time in one browser — for a prefix violation, a missing Secure over plain HTTP, or a mismatched domain — never existed there, so nothing needs deleting; in the other it was stored and now survives the mismatched deletion. Compare the cookie jars rather than the logout responses.

Is an empty value enough, or do I need Max-Age=0?

Send Max-Age=0 (or an Expires in the past). Setting an empty value without an expiry leaves a cookie in place with an empty string, which your application may then treat as a present-but-invalid session — producing a different failure mode rather than a clean logout. Setting both an empty value and a zero max-age is the safe combination.

How do I clear a cookie set on a parent domain?

With a deletion that names the same Domain. A cookie set with Domain=example.com is not deleted by a response omitting the domain, because the identities differ. This is one of several reasons to prefer host-only cookies: with no domain to reproduce, there is one fewer attribute to get wrong, and no subdomain can have set a competing cookie of the same name.

Should logout clear every cookie the application sets?

Clear the session and anti-forgery cookies, and anything else that identifies the user. Leave functional preferences — language, theme, cookie-consent state — alone, since wiping them makes the product feel broken and protects nothing. The test is whether the value would help someone impersonate or identify the user; if not, it can stay.

What if the cookie is set by a framework I do not control?

Use the framework’s own destroy or clear method rather than hand-writing a header, because it knows the options it used. Where that is unavailable, read the actual Set-Cookie from a login response in a test, reproduce its attributes exactly in your deletion, and pin that with an assertion so a framework upgrade that changes the attributes fails the build rather than the logout.