Engineering · Sep 9, 2026 · 7 min read

Insecure randomness: when session tokens are predictable

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.

01Why weak PRNGs shrink the token space

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.

Ideally secrets.token_urlsafe(32) ──► 256 bits of OS entropy ──► 2^256 token space Weak: time-seeded PRNG rand(time()) ──► 32-bit epoch second ──► ~86 400 seeds / day Weak: Math.random() (Xorshift64) Math.random().toString(36) ──► 52 bits of state ──► predictable after ~500 observed values Attacker window: request-timestamp known from HTTP Date header or log
Entropy gap between a CSPRNG and common weak PRNGs. Clock-seeded generators collapse a session-token space to minutes of search time.

02The sample-size oracle: entropy without cracking real sessions

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.

entropy-oracle.pypython
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.

Proven - HighInsecure PRNG - session token - /api/auth/session
25 tokens collected via authenticated probe account, endpoint POST /api/auth/session ──► token length: constant 16 bytes ──► Shannon entropy: 3.1 bits/byte (expected 7.9+ for CSPRNG) ──► prefix_unique: 3 of 25 tokens share a 4-byte prefix (timestamp tell) Baseline: secrets.token_urlsafe(32) scores 7.94 bits/byte, 25/25 unique prefixes.
Oracle: entropy analysis + prefix-uniqueness sample. No real user sessions accessed. Severity high - session token predictability enables account takeover if the timestamp window is known from server-side Date headers.

03Which tokens, which functions, which engines

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 / frameworkUnsafe functionSafe replacementToken context
PHPrand(), 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
Pythonrandom.random(), random.randint()secrets.token_urlsafe(32)Session, API key
Rubyrand()SecureRandom.urlsafe_base64(32)Session, email confirmation
Javajava.util.Randomjava.security.SecureRandomToken, 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.

04Severity follows the privilege the token protects

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.

25
tokens needed to confirm low entropy
3.1
bits/byte observed vs 7.9+ expected
0
real user sessions accessed during probe

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.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →