How to check session id
Collect a batch of identifiers rather than staring at one. curl -s -D - -o /dev/null http://127.0.0.1:8974/login-random | grep set-cookie prints each sid value. Measure length, alphabet and the prefix shared across the batch, then test one hypothesis about how the value was produced. Collect only from an account you own.
Why check this
Run this when a service starts issuing its own identifiers instead of the framework's, after a migration between session stores, and on any release that changes the login response. The failure it prevents is an identifier a person can continue: a counter, a timestamp, a username with a checksum. A batch of twelve shows it, one sample never will.
Three properties are readable without touching anyone else's session: the length, the symbols in use, and whether values in a batch move together. Only the last detects predictability. Everything below runs against a local server, because sampling identifiers from a service you do not own is session guessing under another name.
Prerequisites
- Node 22 or later. Save the three files below.
session-ids.jsissues identifiers three ways on three routes.id-stats.jsmeasures a batch.guess-clock.jstests one hypothesis about a value's source. - curl 8 or later, with the jar columns documented here.
- The OWASP session identifier guidance for the Thresholds numbers.
// session-ids.js - local target for reading session identifiers. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');
let counter = 1000;
const text = { 'content-type': 'text/plain' };
const issuers = {
// 16 bytes from the CSPRNG, printed as 32 hex characters.
'/login-random': () => crypto.randomBytes(16).toString('hex'),
// A counter, encoded so that it does not look like a counter.
'/login-counter': () => Buffer.from(`user-session-${++counter}`).toString('base64url'),
// Derived from the clock. Also 32 hex characters, and one variable behind them.
'/login-time': () => crypto.createHash('md5').update(String(Date.now())).digest('hex'),
};
http.createServer((req, res) => {
const p = new URL(req.url, 'http://127.0.0.1').pathname;
const make = issuers[p];
if (!make) return res.writeHead(404, text).end('Not found\n');
const id = make();
res.writeHead(200, { ...text, 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax` });
res.end(`sid=${id}\n`);
}).listen(8974, () => console.log('session ids on http://127.0.0.1:8974'));
// id-stats.js - reads one identifier per line on stdin and measures what is readable.
let raw = '';
process.stdin.on('data', (d) => { raw += d; }).on('end', () => {
const ids = raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
const lens = ids.map((s) => s.length);
const alphabet = [...new Set(ids.join(''))].sort().join('');
const bitsPerChar = Math.log2(alphabet.length);
let prefix = ids[0] ?? '';
for (const id of ids) { while (!id.startsWith(prefix)) prefix = prefix.slice(0, -1); }
console.log(`samples ${ids.length}, ${new Set(ids).size} distinct`);
console.log(`length ${Math.min(...lens)} to ${Math.max(...lens)} characters`);
console.log(`alphabet ${alphabet.length} symbols: ${alphabet}`);
console.log(`encoding holds ${(Math.min(...lens) * bitsPerChar).toFixed(0)} bits at ${bitsPerChar.toFixed(2)} bits per character`);
console.log(`common prefix ${prefix.length} characters ${prefix ? `("${prefix}")` : ''}`);
});
// guess-clock.js <id> <unix-seconds> - tests one hypothesis about where an id came from.
const crypto = require('node:crypto');
const [id, sec] = process.argv.slice(2);
const centre = Number(sec) * 1000;
let tried = 0;
for (let t = centre - 60_000; t <= centre + 60_000; t += 1) {
tried += 1;
if (crypto.createHash('md5').update(String(t)).digest('hex') === id) {
console.log(`match after ${tried} candidates: md5("${t}")`);
console.log(`issued at ${new Date(t).toISOString()}`);
process.exit(0);
}
}
console.log(`no match in ${tried} candidates`);
Start the target. When you finish, stop the one process holding port 8974, never every process named node.
node session-ids.js
Steps
- Step 1.
Take one identifier and read the attributes around it.
curl -s -c jar2.txt http://127.0.0.1:8974/login-random > /dev/null && cat jar2.txt# Netscape HTTP Cookie File # https://curl.se/docs/http-cookies.html # This file was generated by libcurl! Edit at your own risk. #HttpOnly_127.0.0.1 FALSE / FALSE 0 sid c0048cbba24aae8787aee62936f5ae23The columns are domain, subdomain flag, path, secure flag, expiry, name, value. Here: host only,
/, not secure,0for a cookie that dies with the browser. The#HttpOnly_prefix records that flag. - Step 2.
Collect twelve identifiers from the CSPRNG route.
for i in $(seq 1 12); do curl -s -D - -o /dev/null http://127.0.0.1:8974/login-random | tr -d '\r' | sed -n 's/^set-cookie: sid=\([^;]*\).*/\1/p'; done | tee random.txt && node id-stats.js < random.txt0483e54e66c06cd76e87221e2ec652a5 f7365f933489ba7cc71d3ff136bfa076 … 6e078e2f4f27e6775705ca24dc88a05a samples 12, 12 distinct length 32 to 32 characters alphabet 16 symbols: 0123456789abcdef encoding holds 128 bits at 4.00 bits per character common prefix 0 charactersRead the 128 bit line as a ceiling, not a measurement. It says the container is 32 hex characters wide. What goes into it is step 5.
- Step 3.
Repeat against the counter route and compare the last line.
for i in $(seq 1 12); do curl -s -D - -o /dev/null http://127.0.0.1:8974/login-counter | tr -d '\r' | sed -n 's/^set-cookie: sid=\([^;]*\).*/\1/p'; done | tee counter.txt && node id-stats.js < counter.txtdXNlci1zZXNzaW9uLTEwMDE dXNlci1zZXNzaW9uLTEwMDI … dXNlci1zZXNzaW9uLTEwMTI samples 12, 12 distinct length 23 to 23 characters alphabet 26 symbols: 19ADEILMNQTUWXYZacdgikluwz encoding holds 108 bits at 4.70 bits per character common prefix 21 characters ("dXNlci1zZXNzaW9uLTEwM")Twelve distinct values, 26 symbols, a container the script calls 108 bits wide. The last line matters: 21 of 23 characters never changed.
- Step 4.
Decode the values. A shared prefix is the signal to try common encodings.
node -e "require('fs').readFileSync('counter.txt','utf8').trim().split(/\r?\n/).slice(0,4).forEach(s=>console.log(s,' -> ',Buffer.from(s,'base64url').toString()))"dXNlci1zZXNzaW9uLTEwMDE -> user-session-1001 dXNlci1zZXNzaW9uLTEwMDI -> user-session-1002 dXNlci1zZXNzaW9uLTEwMDM -> user-session-1003 dXNlci1zZXNzaW9uLTEwMDQ -> user-session-1004Base64 raised the apparent alphabet to 26 symbols and the apparent width to 108 bits. The real width is one incrementing integer. Encoding never adds entropy.
- Step 5.
Collect twelve from the clock route. The statistics are the problem.
for i in $(seq 1 12); do curl -s -D - -o /dev/null http://127.0.0.1:8974/login-time | tr -d '\r' | sed -n 's/^set-cookie: sid=\([^;]*\).*/\1/p'; done | tee time.txt && node id-stats.js < time.txt9753d974339c269dd896271a88f30084 d6da2685173defb03f7298cf170389e5 … 01d7545977b812c4cead5436f5dc12e7 samples 12, 12 distinct length 32 to 32 characters alphabet 16 symbols: 0123456789abcdef encoding holds 128 bits at 4.00 bits per character common prefix 0 charactersEvery line matches step 2. Length, alphabet, distinctness and shared prefix cannot separate a CSPRNG from a hash of the millisecond clock.
- Step 6.
Take one identifier, note the second it was issued, and test the hypothesis.
date +%s && curl -s -D - -o /dev/null http://127.0.0.1:8974/login-time | tr -d '\r' | sed -n 's/^set-cookie: sid=\([^;]*\).*/\1/p' > one.txt && cat one.txt1789158975 e69db87912054a053ac8f98b0b281f41Feed both to the guess.
node guess-clock.js $(cat one.txt) 1789158975match after 60459 candidates: md5("1789158975458") issued at 2026-09-11T20:36:15.458ZA two minute window of milliseconds is 120001 candidates, and the value that measured 128 bits wide fell to the 60459th. A step 2 value is the control.
node guess-clock.js 0483e54e66c06cd76e87221e2ec652a5 1789158975no match in 120001 candidates - Step 7.
Open the target in Chrome. DevTools, Console tab.
await (async () => { const store = await cookieStore.getAll(); return { pageBodyShowsIdIssued: document.body.innerText.trim(), documentCookie: document.cookie, cookieStoreNames: store.map((c) => c.name) }; })(){"pageBodyShowsIdIssued":"sid=34ef3253f4ad11dddefe067df4c77e97","documentCookie":"","cookieStoreNames":[]}The page printed the identifier issued a moment earlier, and both script readers returned nothing, because the cookie is
HttpOnly. The browser still holds it. Reload, open DevTools, Network tab, click the document request, Headers panel, and read the request headers.cookie: sid=34ef3253f4ad11dddefe067df4c77e97 … set-cookie: sid=334d27ce8bc3b1592a1832379280d392; Path=/; HttpOnly; SameSite=LaxThat figure is one capture on one machine. Read the identifier in the Network tab, or in the Application tab, Cookies panel.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 32 hex characters, no shared prefix, 12 of 12 distinct | The container is wide enough and the batch shows no structure | Continue to step 6. Statistics alone do not finish this check |
| A long shared prefix across the batch | Part of the value is fixed, so the varying part is all there is | Measure the varying part and decode it. Step 4 |
| Fewer distinct values than samples | The generator repeats inside one batch | Stop and report. A collision at twelve samples is a defect, not a probability |
| The value decodes to readable text | The identifier carries data instead of hiding it | Report what it carries. A username or a counter is enough to continue the series |
| The value reproduces from a timestamp | The generator is the clock | Report as predictable regardless of length |
| document.cookie is empty on a signed-in page | The cookie is HttpOnly, which is what you want | Read the id in the Network or Application tab |
| The cookie has no expiry and dies with the browser | A session cookie, not a persistent one | Confirm that matches the product's remember-me behaviour |
Common mistakes
Thresholds
What to check next
- How to check session fixation: whether this identifier is replaced when the user signs in.
- How to check session timeout: how long one of these values stays usable.
- How to check if cookies are secure and HttpOnly: the two flags read in step 1.
- How to check SameSite cookie attribute: whether the identifier rides on requests from another site.
- How to test login with curl: where the jar comes from.
FAQ
How to get session id from browser?
Open DevTools, Application tab, Storage, Cookies, and select the origin. The value column holds the identifier. document.cookie returns an empty string for an HttpOnly cookie, as step 7 shows.
How to check the domain and path of a session cookie?
Read the jar written by curl -c, shown in step 1. Column one is the domain, column two says whether subdomains match, column three is the path. Host only and / is the narrowest scope, and anything wider is worth a question.
How do I check if a user is logged in in my framework?
That is an implementation question, not a check, and the call differs by framework and by version, so read the documentation for the one you run. What a tester verifies is different: the protected route returns 401 or a redirect without a session and 200 with one. The framework's own answer is not evidence.
How to verify session id?
From outside you verify three things: the value is wide enough, a batch shows no structure, and no hypothesis reproduces it. Verifying that the server accepts it is a different check, done by sending it to a protected route.
Does a longer identifier make the session safer?
Only when the extra characters carry unpredictable bits. The counter in step 3 is 23 characters and continues from one sample. Sixteen hex characters from a cryptographic generator beat fifty from the clock.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.0.0
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
intermediate10 minpublished updated Maks Verny