How to test concurrent sessions

Sign the same account in twice, into two separate cookie jars, then send both identifiers at a protected route: curl -b laptop.txt /account and curl -b phone.txt /account. Two 200 answers mean both sessions are live. A 401 on the older one means the second login evicted the first.

Why check this

Three designs are in production today and all three are defensible. Some services keep every session, some keep one per account, some keep one per device class. The page a tester needs is not an opinion about which is right. It is the sequence that shows which one the service under test actually implements, because the answer is rarely written down and it changes when the session store is replaced.

Run this on staging after a change to the session backend, and before any release that adds a device list or a "sign out everywhere" button. The failure it catches is a product that promises one of the three and delivers another. A support team told that logging in on a new phone kicks the old one off will close tickets on that basis, while the old identifier keeps working for two weeks.

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.

    Sign the same account in twice, writing each login to its own jar.

    curl -s -c laptop.txt http://localhost:8873/login -d 'username=alice&password=correct-horse' && curl -s -c phone.txt http://localhost:8873/login -d 'username=alice&password=correct-horse'
    
    signed in alice sid=9ee4186ea5064d29 ttl=600s
    signed in alice sid=d022f7c5554bae31 ttl=600s

    Two different identifiers for one account. Nothing is proved yet, because a server that issues a new identifier can still have thrown the old one away.

  2. Step 2.

    Send both identifiers at a protected route and read the status codes.

    curl -s -b laptop.txt -w ' [%{http_code}]\n' http://localhost:8873/account && curl -s -b phone.txt -w ' [%{http_code}]\n' http://localhost:8873/account
    
    signed in as alice sid=9ee4186ea5064d29 [200]
    signed in as alice sid=d022f7c5554bae31 [200]

    Both answer 200 and each echoes its own identifier. This service keeps concurrent sessions. Record the sid values, because the next steps ask which of them survives.

  3. Step 3.

    Ask the server what it thinks it is holding for this account.

    curl -s -b laptop.txt http://localhost:8873/sessions
    
    9ee4186ea5064d29 age=0s remembered=false
    d022f7c5554bae31 age=0s remembered=false

    A route like this is what a device list in the account settings is built on. Where the service has one, compare it against the identifiers you hold. A session that works but is not listed is a worse finding than an extra session.

  4. Step 4.

    Log out of the laptop only, then retry both.

    curl -s -b laptop.txt -X POST 'http://localhost:8873/logout?scope=one' && curl -s -b laptop.txt -w ' [%{http_code}]\n' http://localhost:8873/account && curl -s -b phone.txt -w ' [%{http_code}]\n' http://localhost:8873/account
    
    logged out scope=one
    not signed in [401]
    signed in as alice sid=d022f7c5554bae31 [200]

    Logging out ended one session and left the other running. That is the behaviour most users expect, and it is the one to state in the test report, because the opposite is equally common.

  5. Step 5.

    Sign in again on the laptop, then use the wider logout and retry the phone.

    curl -s -c laptop.txt http://localhost:8873/login -d 'username=alice&password=correct-horse' && curl -s -b laptop.txt -X POST 'http://localhost:8873/logout?scope=all' && curl -s -b phone.txt -w ' [%{http_code}]\n' http://localhost:8873/account
    
    signed in alice sid=28b4bb2f58bb7f6d ttl=600s
    logged out scope=all
    not signed in [401]

    The phone never sent a request between the two lines and its session is gone. This is the check behind a "sign out everywhere" button, and it is the one most often shipped without a test.

  6. Step 6.

    Repeat the first two steps against a service that keeps one session per account. The query parameter switches this server to that policy.

    curl -s -c laptop.txt http://localhost:8873/login -d 'username=alice&password=correct-horse' && curl -s -c phone.txt 'http://localhost:8873/login?policy=evict' -d 'username=alice&password=correct-horse' && curl -s -b laptop.txt -w ' [%{http_code}]\n' http://localhost:8873/account && curl -s -b phone.txt -w ' [%{http_code}]\n' http://localhost:8873/account
    
    signed in alice sid=9757b0a2676e3f7c ttl=600s
    signed in alice sid=84b85b242c03c724 ttl=600s
    not signed in [401]
    signed in as alice sid=84b85b242c03c724 [200]

    Identical commands, opposite verdict. The laptop was signed out by a login it never saw, and the only visible difference is the 401 in step 2 that was a 200 before.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Both jars answer 200 in step 2 | Concurrent sessions are kept | Check the session list and the logout scope next | | The older jar answers 401 right after the second login | The second login evicted the first | Confirm the user is told, and check what happens to work in progress | | Both answer 200, and /sessions lists one | The list and the store disagree | Raise it. A session that works and is not listed cannot be revoked by the user | | Step 4 returns 401 for both jars | Logout is account wide | Correct only if the product says so. Otherwise it signs people out of their other devices | | Step 5 leaves the phone at 200 | "Sign out everywhere" ends the current session only | Raise it as a security defect, not a usability one | | The second login returns the same sid | The server reused the identifier instead of issuing a new one | Stop and check what logout does to that identifier |

Common mistakes

Sign: Two browser tabs are opened, both stay signed in, and the service is recorded as allowing concurrent sessions.Cause: Tabs in one profile share one cookie jar, so that is one session used twice. Two sessions need two jars, two profiles, or one profile and one private window.
Sign: Both logins are run with the same curl jar and the first session looks gone.Cause: The jar holds one cookie per name. Run against this server, a second login left only 2e68749cfd7fcd34 in the jar while /sessions still listed 39fd7ee1bf9300fb as live. The first session was alive and unreachable, which is the opposite of the conclusion.
Sign: The older session returns 200 after the second login, so the result is recorded as concurrent sessions allowed.Cause: Some services evict on the next request rather than at login, and some evict only on a write. Retry the older jar after a POST before writing the result down.
Sign: A page still renders for the evicted device, so the tester reports the session as live.Cause: The shell came from the browser cache or from a service worker. Judge on the status code of an API call, which is what step 2 reads, and not on what the screen shows.

What to check next

FAQ

How to check multiple login sessions?

Give each session its own cookie jar, then send each jar at a protected route and compare status codes. Step 2 does exactly that. Where the service exposes a session list, step 3 compares the list against the identifiers that still work.

How to check if a user is logged in on more than one device?

From the outside, by holding two identifiers and finding both accepted. From the inside, by reading the session store or the device list the product exposes. The two answers disagree more often than teams expect, which is why step 3 exists.

Is allowing concurrent sessions a bug?

No. All three designs ship in production. The defect is a mismatch between the design and what the product, the support documentation or the device list claims.

Does an incognito window count as a second device?

Yes for this purpose. A private window has its own cookie store, so it holds a separate session, and it is the quickest second client on a machine with one browser.

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