A session token generated from a weak PRNG or a time-seeded function is not random - it is guessable. Confirming predictability requires a sample-size entropy oracle that detects low entropy without accessing any real user session.
A session token drawn from Math.random(), PHP's rand(), or any PRNG seeded from the system clock is not random in a security sense - it occupies a tiny fraction of the space an attacker has to search. Confirming predictability does not require cracking a real user session: a sample-size entropy oracle demonstrates the weakness without touching any live credential.
Cryptographic session tokens should be drawn from a space so large that a full-speed brute-force is infeasible. A 128-bit secret generated by secrets.token_urlsafe or crypto.randomBytes delivers that. The same token generated by Math.random() - a 64-bit Xorshift state - delivers a space of 2^64, but because the seed is bounded by the system clock and the generator cycles in a predictable sequence, the effective search space is often far smaller. Time-seeded generators collapse that further: an attacker who knows a token was issued within a ten-second window can search every possible seed in that window in seconds.
The confirming probe collects a set of tokens from the registration, password-reset or session-creation endpoint - always as an authenticated probing account, never from another user's flow - and measures three properties: byte-length, character set, and observable structure. A token that encodes a Unix timestamp, a counter, or a low-entropy component in a predictable position reveals itself without any cracking. Shannon entropy over the sample provides a second-order signal: a truly random 128-bit token set shows near-maximum entropy per byte; a timestamp-derived one shows a steep drop in the high-order bytes that encode the seed.
import math, collections, base64 def byte_entropy(tokens: list[bytes]) -> float: # Shannon entropy per byte across the collected sample counts = collections.Counter(b for tok in tokens for b in tok) total = sum(counts.values()) return -sum( (c / total) * math.log2(c / total) for c in counts.values() ) def analyse(raw_tokens: list[str]) -> dict: decoded = [base64.urlsafe_b64decode(t + "==") for t in raw_tokens] lengths = {len(d) for d in decoded} entropy = byte_entropy(decoded) # flag collision or common-prefix across first 4 bytes (timestamp tell) prefixes = [d[:4].hex() for d in decoded] prefix_unique = len(set(prefixes)) return {"lengths": lengths, "entropy_bits_per_byte": entropy, "prefix_unique": prefix_unique, "sample_size": len(decoded)} # Example output on a weak time-seeded generator (25 tokens collected): # {"lengths": {16}, "entropy_bits_per_byte": 3.1, # "prefix_unique": 3, "sample_size": 25} # CSPRNG baseline: entropy 7.9+, prefix_unique == sample_size
Three observations together form the oracle. Low entropy per byte is the first signal. Low prefix-uniqueness is the second: a time-seeded generator that embeds the epoch second in the token's high bytes produces the same prefix for every token issued in the same second - observable directly in the hex dump without any search. A fixed token length that matches a known PHP rand() or Mersenne Twister state size is the third. None of these observations touch a live user session; they only require generating tokens through the application's own public endpoint.
secrets.token_urlsafe(32) scores 7.94 bits/byte, 25/25 unique prefixes.Not every PRNG is equally dangerous. A weak PRNG used only for non-security purposes (random display order, A/B test assignment) is a code quality note. The same generator applied to session identifiers, password-reset tokens, CSRF nonces or email-verification codes is a security finding - because each of these gates real privilege and predictability closes the authentication gap the token is meant to maintain.
| Language / framework | Unsafe function | Safe replacement | Token context |
|---|---|---|---|
| PHP | rand(), mt_rand(), uniqid() | random_bytes(), bin2hex(random_bytes(32)) | Session, password-reset |
| JavaScript (Node) | Math.random() | crypto.randomBytes(32), crypto.randomUUID() | CSRF nonce, reset token |
| Python | random.random(), random.randint() | secrets.token_urlsafe(32) | Session, API key |
| Ruby | rand() | SecureRandom.urlsafe_base64(32) | Session, email confirmation |
| Java | java.util.Random | java.security.SecureRandom | Token, nonce, session ID |
Framework-level configuration can re-introduce the problem after the code is fixed. A PHP session handler that reverts to mt_rand-based IDs when session.entropy_length is low, a Spring application that seeds its token generator from System.currentTimeMillis() in tests and accidentally ships that configuration, or a Node.js session store with genid overridden by a legacy middleware are all sources a file-scan alone will miss. Dynamic confirmation via the entropy oracle catches the runtime behaviour, not just the source.
The severity of a predictable token depends entirely on what an attacker can do once they guess one. A guessable session token on an authenticated endpoint is account takeover - critical. A guessable password-reset token sent by email is critical even if the reset window is short, because the timestamp narrowing approach reduces the search to a few thousand candidates. A guessable CSRF nonce behind Same-Site=Strict cookies may have no exploitable path if the site's cookie policy already blocks cross-origin requests - in that case the finding is high, but the chain is not confirmed. Severity follows the confirmed path, not the theoretical one.
The fix is unambiguous: replace the PRNG with a CSPRNG at the token-generation call site, rotate all live sessions after the deployment, and add a static-analysis rule that flags any import or call of the unsafe function in the token-generation path. Reachability tracking in SAST can follow the call graph from the session handler back to the PRNG import and flag the line; the dynamic entropy oracle confirms the fix held at runtime. One generates the candidate list; the other proves the fix closed it.