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

// 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.

    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=Lax

    One cookie, and no lifetime on it. This is the baseline every later line is compared against.

  2. 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=1209600

    The 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.

  3. 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	9b6f80f1d93991c5

    Column five is the expiry. 0 marks a session cookie in both jars. Only remember has a timestamp, 1790368591, against a clock reading 1789158992 during the run. That is 1209599 seconds, the Max-Age the header asked for.

  4. Step 4.

    Restart the browser. -j discards 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/account
    
    not 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.

  5. 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/account
    
    logged 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.

  6. 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/account
    
    logged 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

Sign: The tester ticks the box, closes the tab, reopens the site, is still signed in, and passes the check.Cause: Closing a tab ends nothing. Session cookies survive until the browser process exits. Quit the browser completely, or reproduce it from a shell with curl -b jar.txt -j, which drops exactly the cookies a restart drops.
Sign: The remember cookie is present and unexpired, so the account is recorded as still reachable.Cause: The cookie proves only what the client kept. Step 6 ends with the same unexpired cookie in the jar and a 401 on the wire, because the server stopped recognising the value. The verdict is the status code, not the jar.
Sign: Remember me is tested while the server session is still alive.Cause: With a live session cookie in the jar, every request succeeds through the ordinary path and the persistent credential is never exercised. Either wait out the session, or drop the session cookie first, which is what -j does.
Sign: The user signs out on one device and the check stops there.Cause: A remember token is usually stored per device and revoked per device. Log out on one and retry the other jar before recording that sign out worked.

What to check next

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.

intermediate9 minpublished updated Maks Verny