How to check if session expires after logout

Copy the cookie jar before logging out, then replay the copy. cp jar.txt before.txt, log out, then curl -b before.txt http://127.0.0.1:8946/account. A session that ended returns 401. A 200 means logout only cleared the browser copy and the record on the server is still valid.

Why check this

Run this on every release that touches the session store, and on staging after a change to the logout handler, the cache in front of it or the session backend. The failure it prevents is a logout that deletes nothing. The user sees the login form and believes they are out, while the session id they used a second ago still opens their account from any client that kept a copy.

That copy is not hypothetical. A session cookie reaches server logs, proxy logs, backup jars, a browser extension and anyone sharing the machine. The whole point of the check is that the attacker holding the cookie is not the client that pressed logout, so nothing the response does to the client matters. Only the server-side record does, and the only way to read it is to replay the old cookie from a client the logout never touched.

Prerequisites

// auth-demo.js - local target for the login and logout checks. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');

const USERS = { alice: 'correct-horse' };
const sessions = new Map();                       // sid -> username

const FORM = `<!doctype html><title>Demo login</title><form method="post" action="/login">
<input name="username"><input name="password" type="password"><button>Sign in</button></form>`;

const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const body = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(s)); });
const text = { 'content-type': 'text/plain' };

http.createServer(async (req, res) => {
  const p = new URL(req.url, 'http://127.0.0.1').pathname;

  if (p === '/') return res.writeHead(200, { 'content-type': 'text/html' }).end(FORM);

  if (p === '/login' && req.method === 'POST') {
    const f = new URLSearchParams(await body(req));
    const u = f.get('username') || '', pw = f.get('password') || '';
    if (!Object.hasOwn(USERS, u)) return res.writeHead(401, text).end(`No account for ${u}\n`);
    if (USERS[u] !== pw) return res.writeHead(401, text).end('Wrong password\n');
    const id = crypto.randomBytes(16).toString('hex');
    sessions.set(id, u);
    return res.writeHead(302, { 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax`, location: '/account' }).end();
  }

  if (p === '/account') {
    if (req.method !== 'GET') return res.writeHead(405, { ...text, allow: 'GET' }).end('Method not allowed\n');
    const id = sid(req);
    if (!id || !sessions.has(id)) return res.writeHead(401, text).end('Not signed in\n');
    return res.writeHead(200, text).end(`Signed in as ${sessions.get(id)}\n`);
  }

  // The defect under test: logout clears the browser cookie and keeps the server record.
  if (p === '/logout' && req.method === 'POST')
    return res.writeHead(302, { 'set-cookie': 'sid=; Path=/; Max-Age=0', location: '/' }).end();

  // The fix: drop the server-side session as well.
  if (p === '/logout-server' && req.method === 'POST') {
    sessions.delete(sid(req));
    return res.writeHead(302, { 'set-cookie': 'sid=; Path=/; Max-Age=0', location: '/' }).end();
  }

  return res.writeHead(404, text).end('Not found\n');
}).listen(8946, () => console.log('auth demo on http://127.0.0.1:8946'));

Start it and hold the process id. Stop that id when you finish, never every process named node.

node auth-demo.js & SRV=$!

Steps

  1. Step 1.

    Sign in and store the session cookie in a jar.

    curl -s -c jar.txt -o /dev/null -w 'login %{http_code}\n' http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse'
    
    login 302
  2. Step 2.

    Confirm the session is live before you touch logout, so a later 401 cannot be blamed on a bad login.

    curl -s -b jar.txt -w '\nstatus %{http_code}\n' http://127.0.0.1:8946/account
    
    Signed in as alice
    
    status 200
  3. Step 3.

    Copy the jar. This copy is the second client, the one logout will never reach.

    cp jar.txt before.txt && grep sid before.txt
    
    #HttpOnly_127.0.0.1	FALSE	/	FALSE	0	sid	348b0a2617d116d0d6a8bf8cb4bc4fb0
  4. Step 4.

    Log out with the live jar and read the response headers.

    curl -s -i -b jar.txt -c jar.txt -X POST http://127.0.0.1:8946/logout | head -4
    
    HTTP/1.1 302 Found
    set-cookie: sid=; Path=/; Max-Age=0
    location: /
    Date: Fri, 11 Sep 2026 20:24:41 GMT

    This is the header most logout tests stop at. Max-Age=0 with an empty value is a correct instruction to the client, and it says nothing about the server.

  5. Step 5.

    Count the sid lines left in the live jar. The client side of logout worked.

    grep -c sid jar.txt
    
    0
  6. Step 6.

    Replay the copy from step 3. This is the evidence the whole procedure exists for.

    curl -s -b before.txt -w '\nstatus %{http_code}\n' http://127.0.0.1:8946/account
    
    Signed in as alice
    
    status 200

    The header in step 4 was correct, the jar in step 5 is empty, and the account still opens. The session record outlived the cookie, which is the defect.

  7. Step 7.

    Run the same sequence against a logout that drops the record, and compare the last line.

    curl -s -c jar.txt -o /dev/null http://127.0.0.1:8946/login -d 'username=alice&password=correct-horse' && cp jar.txt before.txt && curl -s -b jar.txt -c jar.txt -o /dev/null -w 'logout %{http_code}\n' -X POST http://127.0.0.1:8946/logout-server && curl -s -b before.txt -w '\nstatus %{http_code}\n' http://127.0.0.1:8946/account
    
    logout 302
    Not signed in
    
    status 401

    The logout status is the same 302 and the replay now fails. 401 on the replayed cookie is the only result that proves the session ended.

    Stop the server when you are done: kill $SRV.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 401 on the replayed cookie | The server destroyed the session | Nothing. Logout is complete. | | 200 on the replayed cookie | Only the client copy was cleared | Report against the logout handler. It answers the client and never touches the store. | | 200, then 401 a few minutes later | The session died of idle timeout, not of logout | Still a defect. Measure the gap and report the window in which the old cookie worked. | | 302 to the login form on the replay | Frontend routing, not session state | Follow the redirect and read the status of the protected resource itself. | | 200 with an empty body | The route answers before the session check | Pick a resource that returns account data, so the body confirms who the server thinks you are. | | The replay works only in one region | Session stores are not shared across nodes | Repeat against each node directly, bypassing the balancer. |

Common mistakes

Sign: The logout test asserts on the Set-Cookie header and passes.Cause: Step 4 shows a textbook clearing header on a server that keeps the session alive. The header is an instruction to the client, and a client that ignores it, or a copy of the cookie taken earlier, still opens the account.
Sign: The test logs out and reuses the same client to check the protected route.Cause: That client no longer has the cookie, so it gets 401 whatever the server did. The result proves the jar is empty and nothing else. The replay has to come from a copy made before logout, as in step 3.
Sign: A browser test passes because the account page shows the login form after logout.Cause: Single page applications clear their own state on logout and render the form from memory. Read the status of the API call behind the page, not the screen.
Sign: The replay returns 401 in the test environment and 200 in production.Cause: Test runs against one process with an in-memory store, where the record is genuinely gone. Production runs several nodes with a shared store, and a logout that deletes locally leaves the record on every other node.

What to check next

FAQ

How to test logout functionality?

Three assertions, not one. The response clears the cookie, the client jar is empty afterwards, and a copy of the cookie taken before logout is refused. Step 4 and step 5 cover the first two and pass on a broken server. Step 6 is the one that fails.

How to check if a session cookie still works after logout?

Save the cookie to a file before you log out, then send that file with curl -b. Nothing in the logout response reaches that file, so the request arrives with a cookie the server believes it invalidated. The status answers the question.

Why does a token-based logout behave differently?

A signed token carries its own expiry and the server holds no record to delete, so the token stays valid until it expires. Logout there means adding the token to a deny list or shortening its life, and the replay check is the way to find out which one was implemented.

How long may the old session stay valid?

Zero seconds. Logout is an explicit instruction, so a delay is not a tuning value. If the replay works for a while and then stops, the idle timeout is doing the work and the logout handler is not.

Does the same check apply to "log out of all devices"?

Yes, with one jar per device. Sign in twice into two jars, copy both, use the sign-out-everywhere action from one, and replay both copies. Both have to return 401, and the second one is the one that usually does not.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny