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
- Node 22 or later. Save the file below as
session-demo.js. It serves the four cookie shapes, a login, and a second origin used by the sibling checks linked at the end. - curl 8 or later. See curl's HTTP cookie documentation for the jar file format.
- RFC 6265 section 5.2.2 for the rule that decides between
ExpiresandMax-Age. - Chrome for step 3. A browser figure here is one capture on one machine.
// 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
- Step 1.
Print the
Set-Cookieline 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:'; doneset-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=3600The 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
Expiresthat passed an hour ago next to aMax-Agethat has not. - 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 1Column five is the expiry as a Unix timestamp.
shape_sessionholds0, which is how the jar records "no lifetime, goes when the client goes". The other three share1789162546, one hour past the1789158946the clock read during the run.shape_bothlanded with them, so theExpiresin the past was discarded andMax-Agewon. - 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=trueChrome marks
shape_sessionwithexpires=-1andsession=true. It resolvesshape_bothto the same hour ahead that curl chose. Two independent clients, one answer. - 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.txtset-cookie: shape_long=1; Path=/; Max-Age=63072000 localhost FALSE / FALSE 1823719200 shape_long 163072000seconds is 730 days. The stored expiry is1823719200against a clock reading1789159193, 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. - 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/accountsigned 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
Thresholds
What to check next
- How to check cookie flags with curl: the jar format,
-band-c, and why curl's view differs from the browser's. - How to check if cookies are secure and HttpOnly: the two attributes on the same line that decide who can read the cookie.
- How to check SameSite cookie attribute: the attribute that decides whether the cookie is attached at all.
- How to check if session expires after logout: the server-side clock this page kept separate.
- How to test remember me functionality: where a persistent cookie is the feature rather than the defect.
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.
Related on this site
basic8 minpublished updated Maks Verny