How to test remember me functionality
Log in twice, once with the box ticked and once without, and compare the Set-Cookie lines. The remembered login adds a cookie carrying Max-Age. Then replay each jar with curl -b jar.txt -j, which drops session cookies exactly as a browser restart does. Only the remembered one still answers 200.
Why check this
"Remember me" is the one login control that deliberately leaves a credential on disk, so it is the one whose defects survive closing the browser. Run this check before any release that touches the login form or the session store, and every time the session lifetime is changed, because the two are configured in different places and drift apart quietly.
The defect worth hunting is a remembered login that outlives the thing that should end it. The server session ends, the user presses "sign out", and the second credential quietly issues a new session on the next request. Nobody sees it in the browser, because from the user's side the site works.
Prerequisites
- Node 22 or later. Save the file below as
session-demo.js. Sendingremember=onto/loginadds a second cookie, and/accountwill rebuild a session from it. - curl 8 or later.
-jis --junk-session-cookies and is what makes a browser restart testable from a shell. - A jar copied straight after login, so the "before" state is still available after logout.
// 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.
Log in with the box unticked and print every cookie the response sets.
curl -s -o /dev/null -D - -c plain.txt http://localhost:8873/login -d 'username=alice&password=correct-horse' | grep -i '^set-cookie:'set-cookie: sid=493632235a3de72b; Path=/; HttpOnly; SameSite=LaxOne cookie, and no lifetime on it. This is the baseline every later line is compared against.
- Step 2.
Log in again with the box ticked. The form field is whatever the checkbox posts, here
remember=on.curl -s -o /dev/null -D - -c remember.txt http://localhost:8873/login -d 'username=alice&password=correct-horse&remember=on' | grep -i '^set-cookie:'set-cookie: sid=9b6f80f1d93991c5; Path=/; HttpOnly; SameSite=Lax set-cookie: remember=c52a8c4a4c73b2d5; Path=/; HttpOnly; SameSite=Lax; Max-Age=1209600The difference on the wire is one extra cookie with
Max-Age=1209600, which is fourteen days. The session cookie did not change shape. Ticking the box added a credential rather than extending one. - Step 3.
Compare the two jars, because that is where the lifetime becomes a number.
tail -n +4 plain.txt && echo --- && tail -n +4 remember.txt#HttpOnly_localhost FALSE / FALSE 0 sid 493632235a3de72b --- #HttpOnly_localhost FALSE / FALSE 1790368591 remember c52a8c4a4c73b2d5 #HttpOnly_localhost FALSE / FALSE 0 sid 9b6f80f1d93991c5Column five is the expiry.
0marks a session cookie in both jars. Onlyrememberhas a timestamp,1790368591, against a clock reading1789158992during the run. That is 1209599 seconds, theMax-Agethe header asked for. - Step 4.
Restart the browser.
-jdiscards session cookies while loading the jar, which is what a browser does when its process ends.curl -s -b plain.txt -j -w ' [%{http_code}]\n' http://localhost:8873/account && curl -s -b remember.txt -j -w ' [%{http_code}]\n' http://localhost:8873/accountnot signed in [401] restored from remember cookie as alice sid=66c8fcbc37dcb581 [200]The feature works, and the transcript shows how. The unremembered client lost everything. The remembered one arrived with no session cookie at all and left holding a brand new
sid, built from the persistent cookie. A remembered login is a fresh session on every restart, not a long one. - Step 5.
Press sign out, then come back with the same jar. This is where the defect lives.
curl -s -b remember.txt -c remember.txt -o /dev/null http://localhost:8873/account && curl -s -b remember.txt -X POST 'http://localhost:8873/logout?scope=one' && curl -s -b remember.txt -j -w ' [%{http_code}]\n' http://localhost:8873/accountlogged out scope=one restored from remember cookie as alice sid=6e094ba0020553b3 [200]Logout ended the session it was handed and left the remember token alone, so the next request signed the account straight back in. Every session the account had is gone and the account is still reachable, which no session list will show.
- Step 6.
Repeat step 5 against a logout that revokes the token as well, so the report names the fix and not only the fault.
curl -s -b remember.txt -c remember.txt -o /dev/null http://localhost:8873/account && curl -s -b remember.txt -X POST 'http://localhost:8873/logout?scope=all' && curl -s -b remember.txt -j -w ' [%{http_code}]\n' http://localhost:8873/accountlogged out scope=all not signed in [401]Same jar, same commands, one line different in the logout handler. The remember cookie is still on disk and still unexpired. It no longer matches anything the server holds, which is the state a logout is supposed to leave behind.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Ticking the box changes no Set-Cookie line | The control is not wired up | Reproduce in the browser before filing. A proxy can strip a second cookie |
| An extra cookie with Max-Age | The usual design: a persistent token beside a session cookie | Check it is HttpOnly and Secure, and that the value is not the user id |
| The sid itself gains a Max-Age | The session cookie is the remembered credential | Check the server session lifetime matches. A long cookie over a short session logs people out anyway |
| 200 after -j for the unticked login | The login is persistent whether or not the box is ticked | Raise it. The control is decorative and the promise on the form is false |
| 200 after logout, as in step 5 | Logout does not revoke the remember token | Security defect. Every device keeps access after "sign out" |
| The restored response carries a new sid | The token mints sessions on demand | Expected. Check the token itself is rotated, or one stolen copy lasts its full Max-Age |
Common mistakes
What to check next
- How to check cookie expiry: the
Max-Agein step 2, and why the client caps what it stores. - How to check if session expires after logout: the same logout question without the second credential in play.
- How to test concurrent sessions: what a restored session does to the account's other devices.
- How to test login with curl: the jar mechanics these steps rely on.
- How to check if cookies are secure and HttpOnly: the attributes a fourteen-day credential has to carry.
FAQ
How to test the remember me cookie?
Log in twice, with and without the box, and compare Set-Cookie lines. Steps 1 and 2 show the difference: one extra cookie carrying Max-Age. Then replay the jar with -j to confirm the cookie alone can sign the account back in.
How does the remember me feature work?
The server issues a second, long-lived cookie beside the session cookie. When the session cookie is gone, that token is presented instead and the server mints a new session from it. Step 4 shows the exchange, including the new sid that comes back.
Why does the session id change after a restart?
Because the old session ended with the browser. The remember token proves who you are and the server issues a fresh session. A server that returns the previous identifier is reusing sessions, which is a separate defect.
How long should a remember me cookie last?
Long enough to be useful and short enough to be revoked, which the product decides. What testing decides is whether the cookie lifetime, the server token lifetime and the text on the login form agree. They are set in three places and they drift.
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.
Related on this site
intermediate9 minpublished updated Maks Verny