How to check cookie expiry

Print the Set-Cookie line with curl -s -o /dev/null -D - URL | grep -i set-cookie and look for two attributes. A cookie carrying neither Expires nor Max-Age is a session cookie and dies with the browser process. One carrying either survives a restart. When both arrive, Max-Age decides.

Why check this

Run this before a release that touches sign in, and after any change to the login handler or the session store. The defect it catches is a session cookie that outlives the thing it names. A cookie given Max-Age=1209600 while the server record was meant to last an hour keeps a shared machine signed in for two weeks.

The mirror image costs less and is noticed faster. A cookie that expires in an hour against a session lasting a day drops users mid task, and the reports arrive as "it lost my work". Both defects are one header read away.

Prerequisites

// session-demo.js - one local target for the cookie, session, remember-me and CSRF checks.
// Node 22, no dependencies. One process, two origins:
//   the app you own      http://localhost:8873
//   an unrelated site    http://127.0.0.1:8874
const http = require('node:http');
const crypto = require('node:crypto');

const USERS = { alice: 'correct-horse' };
const sessions = new Map();   // sid -> { user, born, dies, remembered, csrf }
const tokens = new Map();     // remember token -> user
const ledger = [];

const text = { 'content-type': 'text/plain' };
const html = { 'content-type': 'text/html' };
const id = () => crypto.randomBytes(8).toString('hex');
const jar = (req, name) => {
  const hit = (req.headers.cookie || '').split(';').map((c) => c.trim().split('='))
    .find(([k]) => k === name);
  return hit ? hit.slice(1).join('=') : null;
};
const body = (req) => new Promise((r) => {
  let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(new URLSearchParams(s)));
});
const live = (sid) => {
  const s = sessions.get(sid);
  if (!s) return null;
  if (Date.now() > s.dies) { sessions.delete(sid); return null; }   // server-side clock
  return s;
};

const app = async (req, res) => {
  const u = new URL(req.url, 'http://localhost:8873');
  const p = u.pathname, q = u.searchParams;
  const hour = 3600e3;

  // ---- four cookie lifetimes, no session involved --------------------------
  if (p === '/cookie/session')
    return res.writeHead(200, { ...text, 'set-cookie': 'shape_session=1; Path=/' }).end('session\n');
  if (p === '/cookie/expires')
    return res.writeHead(200, { ...text, 'set-cookie': `shape_expires=1; Path=/; Expires=${new Date(Date.now() + hour).toUTCString()}` }).end('expires\n');
  if (p === '/cookie/maxage')
    return res.writeHead(200, { ...text, 'set-cookie': 'shape_maxage=1; Path=/; Max-Age=3600' }).end('max-age\n');
  if (p === '/cookie/both')   // Expires already past, Max-Age an hour ahead
    return res.writeHead(200, { ...text, 'set-cookie': `shape_both=1; Path=/; Expires=${new Date(Date.now() - hour).toUTCString()}; Max-Age=3600` }).end('both\n');
  if (p === '/cookie/long')   // two years, past the 400-day cap in RFC 6265bis
    return res.writeHead(200, { ...text, 'set-cookie': 'shape_long=1; Path=/; Max-Age=63072000' }).end('long\n');

  // ---- login. ?ttl=<seconds> server session, ?persist=1 persistent sid cookie
  //      ?policy=evict single session per user, ?cookie=none SameSite=None; Secure
  if (p === '/login' && req.method === 'POST') {
    const f = await body(req);
    const user = f.get('username') || '';
    if (USERS[user] !== f.get('password')) return res.writeHead(401, text).end('bad credentials\n');
    if (q.get('policy') === 'evict') for (const [k, v] of sessions) if (v.user === user) sessions.delete(k);
    const ttl = Number(q.get('ttl') || 600) * 1000;
    const remember = f.get('remember') === 'on';
    const sid = id();
    sessions.set(sid, { user, born: Date.now(), dies: Date.now() + ttl, remembered: remember, csrf: id() });
    const site = q.get('cookie') === 'none' ? '; Secure; SameSite=None' : '; SameSite=Lax';
    const set = [`sid=${sid}; Path=/; HttpOnly${site}${q.get('persist') ? '; Max-Age=3600' : ''}`];
    if (remember) {
      const t = id(); tokens.set(t, user);
      set.push(`remember=${t}; Path=/; HttpOnly${site}; Max-Age=1209600`);
    }
    return res.writeHead(200, { ...text, 'set-cookie': set }).end(`signed in ${user} sid=${sid} ttl=${ttl / 1000}s\n`);
  }

  if (p === '/account') {
    const sid = jar(req, 'sid'), s = live(sid);
    if (s) return res.writeHead(200, text).end(`signed in as ${s.user} sid=${sid}\n`);
    const t = jar(req, 'remember');
    if (t && tokens.has(t)) {                       // the remember-me path
      const user = tokens.get(t), fresh = id();
      sessions.set(fresh, { user, born: Date.now(), dies: Date.now() + 600e3, remembered: true, csrf: id() });
      return res.writeHead(200, { ...text, 'set-cookie': `sid=${fresh}; Path=/; HttpOnly; SameSite=Lax` })
        .end(`restored from remember cookie as ${user} sid=${fresh}\n`);
    }
    return res.writeHead(401, text).end('not signed in\n');
  }

  if (p === '/sessions') {
    const s = live(jar(req, 'sid'));
    if (!s) return res.writeHead(401, text).end('not signed in\n');
    const rows = [...sessions].filter(([, v]) => v.user === s.user && v.dies > Date.now())
      .map(([k, v]) => `${k} age=${Math.round((Date.now() - v.born) / 1000)}s remembered=${v.remembered}`);
    return res.writeHead(200, text).end(`${rows.join('\n')}\n`);
  }

  // ?scope=one drops this session only, ?scope=all drops every session and token
  if (p === '/logout' && req.method === 'POST') {
    const sid = jar(req, 'sid'), s = sessions.get(sid), scope = q.get('scope') || 'one';
    if (scope === 'all' && s) {
      for (const [k, v] of sessions) if (v.user === s.user) sessions.delete(k);
      for (const [k, v] of tokens) if (v === s.user) tokens.delete(k);
    } else sessions.delete(sid);
    return res.writeHead(200, { ...text, 'set-cookie': 'sid=; Path=/; Max-Age=0' }).end(`logged out scope=${scope}\n`);
  }

  // ---- CSRF: /transfer checks the token, /transfer-legacy checks nothing ----
  if (p === '/form') {
    const s = live(jar(req, 'sid'));
    if (!s) return res.writeHead(401, text).end('not signed in\n');
    return res.writeHead(200, html).end(`<form method="post" action="/transfer">
<input type="hidden" name="csrf" value="${s.csrf}">
<input name="to"><input name="amount"><button>Send</button></form>`);
  }
  if ((p === '/transfer' || p === '/transfer-legacy') && req.method === 'POST') {
    const s = live(jar(req, 'sid'));
    if (!s) return res.writeHead(401, text).end('no session cookie, nothing to forge\n');
    const f = await body(req);
    if (p === '/transfer' && f.get('csrf') !== s.csrf)
      return res.writeHead(403, text).end('csrf token missing or wrong\n');
    ledger.push(`${s.user} -> ${f.get('to')} ${f.get('amount')} via ${p} origin=${req.headers.origin || 'none'}`);
    return res.writeHead(200, text).end(`sent ${f.get('amount')} to ${f.get('to')}\n`);
  }
  if (p === '/ledger') return res.writeHead(200, text).end(`${ledger.join('\n')}\n`);

  return res.writeHead(404, text).end('not found\n');
};

// The second origin. It is the same machine and a different site: cookies set on
// localhost are cross-site for a page served from 127.0.0.1.
const other = (req, res) => {
  const path = new URL(req.url, 'http://127.0.0.1:8874').searchParams.get('path') || '/transfer-legacy';
  res.writeHead(200, html).end(`<!doctype html><title>Unrelated site</title><p>An article.</p>
<form id="f" method="post" action="http://localhost:8873${path}">
<input type="hidden" name="to" value="mallory"><input type="hidden" name="amount" value="9000"></form>
<script>document.getElementById('f').submit()</script>`);
};

http.createServer(app).listen(8873, () => console.log('app    http://localhost:8873'));
http.createServer(other).listen(8874, () => console.log('other  http://127.0.0.1:8874'));

Confirm the port has no listener, start the server, then read its real process id so you can stop that one process at the end.

netstat -ano | grep ':8873 ' ; node session-demo.js &

Steps

  1. Step 1.

    Print the Set-Cookie line for each of the four lifetimes the server can send.

    for s in session expires maxage both; do curl -s -o /dev/null -D - "http://localhost:8873/cookie/$s" | grep -i '^set-cookie:'; done
    
    set-cookie: shape_session=1; Path=/
    set-cookie: shape_expires=1; Path=/; Expires=Fri, 11 Sep 2026 21:35:46 GMT
    set-cookie: shape_maxage=1; Path=/; Max-Age=3600
    set-cookie: shape_both=1; Path=/; Expires=Fri, 11 Sep 2026 19:35:46 GMT; Max-Age=3600

    The wall clock during the run read 20:35:46 UTC. Line 2 expires an hour ahead, line 3 lives an hour from the moment the response arrives, and line 4 carries an Expires that passed an hour ago next to a Max-Age that has not.

  2. Step 2.

    Let curl decide what each of those lines means, by collecting them in a jar.

    for s in session expires maxage both; do curl -s -o /dev/null -c shapes.txt -b shapes.txt "http://localhost:8873/cookie/$s"; done && cat shapes.txt
    
    # Netscape HTTP Cookie File
    # https://curl.se/docs/http-cookies.html
    # This file was generated by libcurl! Edit at your own risk.
    
    localhost	FALSE	/	FALSE	1789162546	shape_both	1
    localhost	FALSE	/	FALSE	1789162546	shape_expires	1
    localhost	FALSE	/	FALSE	0	shape_session	1
    localhost	FALSE	/	FALSE	1789162546	shape_maxage	1

    Column five is the expiry as a Unix timestamp. shape_session holds 0, which is how the jar records "no lifetime, goes when the client goes". The other three share 1789162546, one hour past the 1789158946 the clock read during the run. shape_both landed with them, so the Expires in the past was discarded and Max-Age won.

  3. Step 3.

    Ask a browser the same question, because the bug report you are chasing came from a browser.

    node -e "import('./scripts/browser/session.mjs').then(async ({ open }) => { const s = await open(); for (const p of ['session','expires','maxage','both']) await s.goto('http://localhost:8873/cookie/' + p); const { cookies } = await s.cdp.send('Network.getAllCookies'); console.log('now', Math.floor(Date.now() / 1000)); for (const c of cookies.filter((c) => c.name.startsWith('shape_')).sort((a, b) => a.name.localeCompare(b.name))) console.log(c.name, 'expires=' + c.expires, 'session=' + c.session); await s.close(); })"
    
    now 1789158964
    shape_both expires=1789162563.158137 session=false
    shape_expires expires=1789162561.144007 session=false
    shape_maxage expires=1789162562.149115 session=false
    shape_session expires=-1 session=true

    Chrome marks shape_session with expires=-1 and session=true. It resolves shape_both to the same hour ahead that curl chose. Two independent clients, one answer.

  4. Step 4.

    Ask for a two-year cookie and read back what you were actually given.

    curl -s -o /dev/null -D - -c long.txt http://localhost:8873/cookie/long | grep -i set-cookie && grep shape_long long.txt
    
    set-cookie: shape_long=1; Path=/; Max-Age=63072000
    localhost	FALSE	/	FALSE	1823719200	shape_long	1

    63072000 seconds is 730 days. The stored expiry is 1823719200 against a clock reading 1789159193, a difference of 34560007 seconds, which is 400 days. Chrome clamped the same cookie to 400 days in the same run. The attribute you send is a request, not a setting.

  5. Step 5.

    Show that the cookie clock and the session clock are unrelated. Sign in with a cookie that lasts an hour and a server session that lasts five seconds.

    curl -s -c two.txt "http://localhost:8873/login?persist=1&ttl=5" -d 'username=alice&password=correct-horse' && grep sid two.txt && sleep 6 && grep sid two.txt && curl -s -b two.txt -w ' [%{http_code}]\n' http://localhost:8873/account
    
    signed in alice sid=39071cd5c095bc4e ttl=5s
    #HttpOnly_localhost	FALSE	/	FALSE	1789162546	sid	39071cd5c095bc4e
    #HttpOnly_localhost	FALSE	/	FALSE	1789162546	sid	39071cd5c095bc4e
    not signed in [401]

    The jar line is identical before and after the wait, with the expiry still 3594 seconds away. The client is certain it holds a valid cookie. The server disagrees, because the record the cookie points at was dropped on its own schedule.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | No Expires and no Max-Age | Session cookie. It ends with the browser process | Correct for a plain login. Wrong if the product promises to keep the user signed in | | Max-Age=3600 | Persistent for an hour from the moment the response arrived | Compare it against the server session lifetime before accepting it | | Expires set to a past date | A deletion, not a lifetime | Expected on a logout response. On a login response it is a defect | | Both attributes present | Max-Age decides and Expires is ignored | Keep the two in step anyway, for the proxies and libraries that read only one | | 0 in column five of a curl jar | curl stored it as a session cookie | Confirm with curl -b jar.txt -j, which drops exactly these | | Cookie still valid, request answered 401 | The server session ended first | Read the session store timeout, not the cookie |

Common mistakes

Sign: The cookie carries Max-Age=1209600, so the tester records the session as lasting fourteen days.Cause: The cookie lifetime and the session record lifetime are two clocks with no link between them. In step 5 the cookie had 3594 seconds left and the account route still answered 401, because the server dropped its own record after five seconds.
Sign: A Set-Cookie line carries an Expires date in the past and the check is logged as a broken date.Cause: That is the documented way to delete a cookie. The same shape appears on a logout response and on a login response, and only one of the two is a defect.
Sign: The response asks for a two-year cookie, so the test plan says the login lasts two years.Cause: Clients cap what they store. curl 8.21.0 and Chrome 152 both cut a 730-day request down to 400 days on 2026-09-11. Read the value back from the jar or from the browser rather than trusting the header you sent.
Sign: DevTools shows an expiry the curl jar does not have, or the reverse.Cause: A jar is written when the transfer ends, so a cookie set on a redirect you did not follow never reaches it. Add -L, or read the headers of every hop with -D.

Thresholds

400 days is the longest cookie lifetime a client will store, whatever Max-Age asks for Source: RFC 6265bis section 5.5, confirmed on 2026-09-11 by curl 8.21.0 and Chrome 152 clamping the same Max-Age=63072000 to 34560007 seconds

What to check next

FAQ

How to check cookie expiry time?

Step 1 reads the attribute off the wire. Step 2 turns it into a timestamp by letting curl resolve it, which is the number that matters, because the client and not the server decides when the cookie goes.

How to tell if cookies are expired?

An expired cookie is absent rather than visible. Send the jar and read the request line: curl -b jar.txt -v URL 2>&1 | grep -i '^> cookie'. A name missing there was dropped by the client.

Cookie expires vs Max-Age, which one wins?

Max-Age wins when both are present. RFC 6265 section 5.2.2 states the rule, and steps 2 and 3 show curl and Chrome both keeping a Max-Age hour over an Expires that had already passed.

What does cookie expires session mean?

The Set-Cookie line carried no lifetime, so the client holds the cookie in memory and drops it when the browser process ends. DevTools prints the word "Session" in the Expires column and a curl jar writes 0.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76

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.

basic8 minpublished updated Maks Verny