How to check session timeout

Log in, wait without sending anything, then send one request. curl -b jar.txt http://127.0.0.1:8973/account answers 200 inside the window and 401 once the server has dropped the record. The cookie's Max-Age is the client's copy of the deadline and carries no authority, so read the status code and time the wait yourself.

Why check this

Run this on staging after a change to the session store, the cookie attributes, the load balancer or the framework, and before a release that names a timeout value in its documentation. The failure it prevents is a session advertised as fifteen minutes that lives for eight hours, or one that ends while the user is typing because two components hold different numbers.

Two clocks end a session for different reasons. The idle timeout measures the gap since the last request and resets on every one. The absolute timeout measures the age of the session and resets on nothing. A test that polls until the session dies holds the idle clock at zero, so it measures the absolute timeout and reports it as the idle one.

Prerequisites

// session-clocks.js - local target for the session timeout checks. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');

const IDLE_MS = 10_000;      // the server drops a session after 10 s with no request
const ABSOLUTE_MS = 25_000;  // and always at 25 s, however active the client is
const sessions = new Map();  // sid -> { user, createdAt, lastSeen }
const text = { 'content-type': 'text/plain' };
const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;

function issue(res, maxAge) {
  const id = crypto.randomBytes(16).toString('hex');
  const now = Date.now();
  sessions.set(id, { user: 'alice', createdAt: now, lastSeen: now });
  res.writeHead(200, { ...text, 'set-cookie': `sid=${id}; Path=/; Max-Age=${maxAge}; HttpOnly` });
  res.end(`signed in, cookie Max-Age=${maxAge}\n`);
}

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

  // The cookie outlives the server record: the client keeps it for an hour.
  if (p === '/login') return issue(res, 3600);
  // The reverse: the cookie expires in 5 s, the server record lives its full life.
  if (p === '/login-short') return issue(res, 5);

  if (p === '/account') {
    const id = sid(req);
    const s = id && sessions.get(id);
    if (!s) return res.writeHead(401, { ...text, 'x-session-end': 'unknown' }).end('Not signed in\n');
    const now = Date.now();
    const idle = Math.round((now - s.lastSeen) / 1000);
    const age = Math.round((now - s.createdAt) / 1000);
    if (now - s.lastSeen > IDLE_MS) {
      sessions.delete(id);
      return res.writeHead(401, { ...text, 'x-session-end': 'idle' }).end(`Idle for ${idle} s\n`);
    }
    if (now - s.createdAt > ABSOLUTE_MS) {
      sessions.delete(id);
      return res.writeHead(401, { ...text, 'x-session-end': 'absolute' }).end(`Age ${age} s\n`);
    }
    s.lastSeen = now;
    return res
      .writeHead(200, { ...text, 'x-session-idle': String(idle), 'x-session-age': String(age) })
      .end(`Signed in as ${s.user}\n`);
  }

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

Start it. When you finish, stop the one process holding the port, never every process named node.

node session-clocks.js

Steps

  1. Step 1.

    Log in and read the deadline handed to the client.

    curl -s -i -c jar.txt http://127.0.0.1:8973/login | head -4
    
    HTTP/1.1 200 OK
    content-type: text/plain
    set-cookie: sid=81b6f57cb4bb0da0d8b00e747a5350d0; Path=/; Max-Age=3600; HttpOnly
    Date: Fri, 11 Sep 2026 20:30:50 GMT

    Max-Age=3600 is the only timeout value most tests read. Write it down as the claim, not the answer.

  2. Step 2.

    Wait twelve seconds without sending anything, then send one request and read the status.

    sleep 12 && curl -s -D - -b jar.txt http://127.0.0.1:8973/account | grep -Ei '^HTTP|x-session|^Idle|^Signed|^Not'
    
    HTTP/1.1 401 Unauthorized
    x-session-end: idle
    Idle for 12 s

    The client disagrees. The cookie has 3588 seconds left and the session behind it is gone.

    awk -F'\t' '/sid/{print "cookie expires at", strftime("%Y-%m-%d %H:%M:%S", $5)}' jar.txt && date +"request sent at    %Y-%m-%d %H:%M:%S"
    
    cookie expires at 2026-09-12 00:30:50
    request sent at    2026-09-11 23:31:02
  3. Step 3.

    Log in again and poll every four seconds. This is the shape most timeout tests take, and it measures the wrong clock.

    curl -s -c jar.txt -o /dev/null http://127.0.0.1:8973/login && start=$(date +%s) && for i in $(seq 1 9); do printf '%3ds  ' $(( $(date +%s) - start )); curl -s -D - -o /dev/null -b jar.txt http://127.0.0.1:8973/account | tr -d '\r' | grep -Ei '^HTTP|^x-session-(age|end)' | tr '\n' ' '; echo; sleep 4; done
    
      1s  HTTP/1.1 200 OK x-session-age: 0
    5s  HTTP/1.1 200 OK x-session-age: 4
    9s  HTTP/1.1 200 OK x-session-age: 9
    13s  HTTP/1.1 200 OK x-session-age: 13
    17s  HTTP/1.1 200 OK x-session-age: 17
    21s  HTTP/1.1 200 OK x-session-age: 21
    25s  HTTP/1.1 401 Unauthorized x-session-end: absolute
    30s  HTTP/1.1 401 Unauthorized x-session-end: unknown
    34s  HTTP/1.1 401 Unauthorized x-session-end: unknown

    The session was still open at 21 seconds on a server whose idle timeout is ten. Every poll reset the idle clock, so the run ended on the absolute clock at 25 seconds. Reporting 25 as the timeout is wrong twice: the idle limit was never reached, and the number belongs to another setting. The reason is also available once. At 30 seconds the record is gone and the server can no longer say why.

  4. Step 4.

    Measure the idle timeout with one probe per login, changing only the wait. Nothing else touches the session.

    for w in 6 8 10 11 12; do curl -s -c jar.txt -o /dev/null http://127.0.0.1:8973/login; sleep $w; printf 'idle %2ds  ' $w; curl -s -D - -o /dev/null -b jar.txt http://127.0.0.1:8973/account | tr -d '\r' | grep -Ei '^HTTP|^x-session-end' | tr '\n' ' '; echo; done
    
    idle  6s  HTTP/1.1 200 OK
    idle  8s  HTTP/1.1 200 OK
    idle 10s  HTTP/1.1 401 Unauthorized x-session-end: idle
    idle 11s  HTTP/1.1 401 Unauthorized x-session-end: idle
    idle 12s  HTTP/1.1 401 Unauthorized x-session-end: idle

    The boundary sits between 8 and 10 seconds, and the configured value is 10. A probe at the limit lands on the far side because the sleep, the process start and the request all add to the gap. Report the bracket separately from the configured value.

  5. Step 5.

    Run the opposite case. /login-short sends a five second cookie.

    curl -s -i -c short.txt http://127.0.0.1:8973/login-short | head -3
    
    HTTP/1.1 200 OK
    content-type: text/plain
    set-cookie: sid=47193a7f5b62996a2b0dbbee965d358e; Path=/; Max-Age=5; HttpOnly
  6. Step 6.

    Six seconds later, send the jar. curl drops the cookie before the request leaves.

    sleep 6 && curl -s -v -b short.txt http://127.0.0.1:8973/account 2>&1 | grep -Ei '^> (GET|Cookie)|^< HTTP|^< x-session-end|^Not|^Signed'
    
    > GET /account HTTP/1.1
    < HTTP/1.1 401 Unauthorized
    < x-session-end: unknown
    Not signed in

    There is no Cookie: line in the request. The client produced this 401, not the session store, and unknown means the server was handed nothing.

  7. Step 7.

    In the same second, send the saved value by hand.

    curl -s -D - -H "Cookie: sid=47193a7f5b62996a2b0dbbee965d358e" http://127.0.0.1:8973/account | grep -Ei '^HTTP|^x-session|^Signed|^Not'
    
    HTTP/1.1 200 OK
    x-session-idle: 6
    x-session-age: 6
    Signed in as alice

    Same value, same second, opposite answer. Cookie expiry is a rule the client applies to itself, and any holder of a copy skips it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 401 after a measured wait, no request in between | The idle timeout ended the session | Record the bracket, the two waits either side of the boundary | | 200 well past the idle timeout while polling | Every request reset the idle clock | Stop polling. One probe per login, as in step 4 | | 401 at a fixed age however often you poll | The absolute timeout ended the session | Report it as the absolute value, separate from the idle one | | 401 with no Cookie header in the request | The client expired the cookie, the server never saw it | Resend the value by hand to find out whether the session is alive | | 200 from a hand-sent value after the cookie expired | The record outlives the cookie | Report against the session store. Max-Age is not an expiry mechanism | | The boundary moves between runs | The clock restarts on something you are not controlling | Check for a keepalive poll, a service worker, or a second node with its own store |

Common mistakes

Sign: The test polls the protected route every few seconds and reports the first 401 as the session timeout.Cause: Each poll is activity, so the idle clock never advances. What the run measures is the absolute timeout, or nothing at all when there is no absolute timeout and the loop runs until the test gives up. Step 3 prints a 200 at 21 seconds on a ten second idle timeout.
Sign: The timeout is read from the cookie's Max-Age or Expires attribute and never measured.Cause: Max-Age tells the client when to delete its copy. Nothing sends that number to the session store, and in most frameworks the two are configured in different files. Step 1 and step 2 show a one hour cookie over a ten second session.
Sign: A 401 after the wait is accepted as proof, without checking what was sent.Cause: When the cookie expired first, the client omits it and the 401 means the request was anonymous. The session may still be open, as step 7 shows. Read the request line for a Cookie header before you trust the status.
Sign: The measured timeout is shorter on the deployed system than in the framework configuration.Cause: Something in front of the application has its own limit: a reverse proxy session affinity window, a token in a gateway, or a session store eviction policy. Measure again against a single node directly to find out which layer ended it.

What to check next

FAQ

How to test session timeout in a web application?

Log in, leave the browser untouched for the configured period, then click one link that calls the server. The screen may still show the account page from memory, so read the network response for that click.

How to test auto logout after inactivity?

Separate the two things that phrase covers. The server side is step 4. The client side is a timer in the page, checked by leaving the tab open and watching whether the redirect fires with no request first.

How to check session timeout of a website?

On a service you do not own, log in once, probe once, and accept a bracket rather than a number. Repeated login cycles against a third party are a load test nobody agreed to. Measure properly on your own build.

Can I read the timeout from the cookie?

No. Max-Age and Expires instruct the client, and step 7 shows a request succeeding after both had passed. Use them only to check that the client copy does not outlive the session by hours.

Why does the session end earlier than configured?

Usually a layer above the application: a proxy pinning a client to one node for a shorter window, a token with its own expiry, or a store evicting under memory pressure. Measure again against one node directly.

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.

intermediate12 minpublished updated Maks Verny