Java SecureRandom, Deeply: Seeding, Predictability, Threads, and Minting Tokens

⚠️ This post is generated by LLM, read with caution.

Every session token, nonce, and key that a Java service issues is ultimately one thing: a run of random bytes. The JDK’s answer for “give me random bytes I can defend” is java.security.SecureRandom — a service abstraction that stands in front of whatever platform entropy source or CSPRNG the installed providers implement. The JDK 21 Javadoc for the class is direct about the bar: every implementation’s output sequences “must be cryptographically strong, as described in RFC 4086.”

Knowing that the class exists is the easy part. The interesting territory is where its behavior diverges from the java.util.Random most people already know: how it gets seeded, which named algorithms a JDK actually ships, what setSeed does to an output stream, and why two numbers from the wrong RNG leak everything. Five small programs cover it: the everyday API, the algorithms in the box, a live prediction attack on java.util.Random, sharing one instance across eight threads, and the token-minting idiom.

The everyday API

The working API is three verbs: nextBytes, nextInt, nextLong. You hand it an array or a bound; it fills the output. The complete program:

import java.security.Security;
import java.security.SecureRandom;
import java.util.Arrays;

/**
 * The everyday API: pull bytes, ints, and longs out of a SecureRandom.
 */
public class Basics {

    static String hex(byte[] b) {
        StringBuilder sb = new StringBuilder();
        for (byte x : b) sb.append(String.format("%02x", x));
        return sb.toString();
    }

    public static void main(String[] args) {
        SecureRandom rng = new SecureRandom();

        System.out.println("provider  : " + rng.getProvider().getName());
        System.out.println("algorithm : " + rng.getAlgorithm());
        System.out.println("algorithms offered: " + Security.getAlgorithms("SecureRandom"));
        System.out.println();

        // nextBytes: the workhorse. The caller supplies the array; the
        // implementation fills EVERY byte with fresh random material.
        byte[] sixteen = new byte[16];
        rng.nextBytes(sixteen);
        System.out.println("nextBytes(16)      : " + hex(sixteen));
        System.out.println("nextBytes(16) again: " + freshHex(rng, 16));
        System.out.println();

        // nextInt / nextLong: random values in specific ranges.
        System.out.println("nextInt()        = " + rng.nextInt());      // all 32 bits
        System.out.println("nextInt(1000)    = " + rng.nextInt(1000));  // [0, 1000)
        System.out.println("nextInt(-5)      = " + safely(rng));        // bound must be > 0
        System.out.println("nextLong()       = " + rng.nextLong());
        System.out.println("nextLong() & 7   = " + (rng.nextLong() & 7));
        System.out.println();

        // pass in a "dirty" array: nextBytes overwrites every byte,
        // it never leaves your old values behind
        byte[] dirty = { 1, 2, 3, 4 };
        rng.nextBytes(dirty);
        System.out.println("overwrote [1, 2, 3, 4] with: " + Arrays.toString(dirty));
    }

    static String freshHex(SecureRandom rng, int n) {
        byte[] b = new byte[n];
        rng.nextBytes(b);
        return hex(b);
    }

    static int safely(SecureRandom rng) {
        try {
            return rng.nextInt(-5);
        } catch (IllegalArgumentException e) {
            System.out.println("  (threw " + e + ")");
            return 0;
        }
    }
}

Running it on this JDK 21 (Temurin) build:

provider  : SUN
algorithm : NativePRNG
algorithms offered: [DRBG, SHA1PRNG, NATIVEPRNGBLOCKING, NATIVEPRNGNONBLOCKING, NATIVEPRNG]

nextBytes(16)      : 46eda0e2a59c3a7e2c1a88e3023ad067
nextBytes(16) again: 4d58794c6c6a65e7dd1346793748103c

nextInt()        = 750080324
nextInt(1000)    = 504
  (threw java.lang.IllegalArgumentException: bound must be positive)
nextInt(-5)      = 0
nextLong()       = 8599262099018464827
nextLong() & 7   = 3

overwrote [1, 2, 3, 4] with: [55, 75, 8, 17]

Three things in that output are worth keeping. The default new SecureRandom() landed on NativePRNG from the SUN provider here — the Javadoc only says the no-arg constructor “implements the default random number algorithm,” so the concrete algorithm is provider-specific, and getAlgorithm()/getProvider() are how you find out which one you actually got. Passing in a pre-dirtied array [1, 2, 3, 4] came back completely overwritten: nextBytes fills every position, so there’s no half-random byte left for the old value to bleed through. And the bound is enforced for you — nextInt(-5) throws IllegalArgumentException: bound must be positive rather than returning a silently-wrong value, in contrast to a hand-rolled Math.abs(rng.nextInt()) % mod scheme, which silently accepts a negative bound.

Which algorithms are actually in the box?

SecureRandom is a service name, resolved through the same Security registry as MessageDigest and friends. So a program that asks for a specific algorithm is writing against a promise the provider might not keep — getInstance(name) declares NoSuchAlgorithmException for exactly that reason. The next program probes three names and then demonstrates the one seeded behavior that genuinely surprises people:

import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.security.SecureRandom;

/**
 * Named algorithms, and what "seeded" really means for a PRNG SecureRandom.
 */
public class Algorithms {

    public static void main(String[] args) throws Exception {
        // Which algorithms does this JDK register under this service name?
        System.out.println("Security.getAlgorithms(\"SecureRandom\") -> "
                + String.join(", ", Security.getAlgorithms("SecureRandom")));
        System.out.println();

        // getInstance by name: the exception path is part of the API,
        // because not every algorithm exists on every platform/JCE build.
        String[] candidates = { "NativePRNG", "SHA1PRNG", "DRBG" };
        for (String name : candidates) {
            try {
                SecureRandom rng = SecureRandom.getInstance(name);
                byte[] b = new byte[4];
                rng.nextBytes(b);
                System.out.printf("getInstance(\"%-10s\") -> %s, sample: %02x%02x%02x%02x%n",
                        name, rng.getAlgorithm(), b[0], b[1], b[2], b[3]);
            } catch (NoSuchAlgorithmException e) {
                System.out.printf("getInstance(\"%-10s\") -> not available: %s%n",
                        name, e.getMessage());
            }
        }
        System.out.println();

        // Seeding a PRNG: the same seed drives the SAME output stream.
        // That is the opposite of a CSPRNG, whose output must not be
        // reproducible from the seed alone.
        byte[] seedA = { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, (byte) 0x88 };
        byte[] seedB = { (byte) 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11 };

        int a1First = firstInt(seeded("SHA1PRNG", seedA));
        int a2First = firstInt(seeded("SHA1PRNG", seedA));
        int b1First = firstInt(seeded("SHA1PRNG", seedB));

        System.out.println("SHA1PRNG seeded with A        -> first nextInt(): " + a1First);
        System.out.println("SHA1PRNG seeded with A (new)  -> first nextInt(): " + a2First
                + "   same stream? " + (a1First == a2First));
        System.out.println("SHA1PRNG seeded with B        -> first nextInt(): " + b1First
                + "   different?   " + (a1First != b1First));
        System.out.println();
        System.out.println("=> Seeded SecureRandom is fully determined by its seed.");
        System.out.println("   Fine as a test fixture. Never use it to mint tokens.");
    }

    static SecureRandom seeded(String alg, byte[] seed) throws NoSuchAlgorithmException {
        SecureRandom rng = SecureRandom.getInstance(alg);
        rng.setSeed(seed);
        return rng;
    }

    static int firstInt(SecureRandom rng) {
        return rng.nextInt();
    }
}
Security.getAlgorithms("SecureRandom") -> DRBG, SHA1PRNG, NATIVEPRNGBLOCKING, NATIVEPRNGNONBLOCKING, NATIVEPRNG

getInstance("NativePRNG") -> NativePRNG, sample: bfde61f7
getInstance("SHA1PRNG  ") -> SHA1PRNG, sample: 62684465
getInstance("DRBG      ") -> DRBG, sample: 557f4aef

SHA1PRNG seeded with A        -> first nextInt(): -1318033465
SHA1PRNG seeded with A (new)  -> first nextInt(): -1318033465   same stream? true
SHA1PRNG seeded with B        -> first nextInt(): 1616567167   different?   true

=> Seeded SecureRandom is fully determined by its seed.
   Fine as a test fixture. Never use it to mint tokens.

All three requested algorithms resolved on this build, so the exception path didn’t fire here — but note that this is a build-dependent fact, not a language one; the Javadoc keeps NoSuchAlgorithmException on getInstance(String) because some algorithm won’t be present everywhere.

The second half is the subtle bit. SHA1PRNG is a PRNG: give it a seed and the entire stream is a function of that seed. Two independent instances seeded with the same eight bytes produced the same first nextInt() — and I reran the whole program in a fresh JVM and got the same values again (seed B’s 1616567167 was identical across both invocations). This is exactly what the Javadoc is describing: a newly created PRNG SecureRandom “is not seeded,” and the first nextBytes call makes it seed itself — “This self-seeding will not occur if setSeed was previously called.” In other words, calling setSeed is not “add some extra entropy.” It replaces the entropy source with your bytes. That makes seeded SecureRandom perfect for one job — reproducible test fixtures — and disqualifying for the other, minting anything an attacker can see: if the seed is guessable or logged, so is the token.

Why SecureRandom exists: two numbers are enough

Here’s the failure mode the class is built to prevent, demonstrated rather than asserted. java.util.Random is a linear congruential generator. The JDK 21 source for Random says it all in two lines — the state is 48 bits, stepped by nextseed = (oldseed * multiplier + addend) & mask with multiplier = 0x5DEECE66DL and addend = 0xBL — and each nextInt() returns nextseed >>> (48 - bits), i.e. the top 32 bits of the post-step state.

That has a consequence: one observed nextInt() fixes the top 32 bits of one state, leaving only 2¹⁶ possibilities for the low bits. The second observed nextInt() picks exactly one of those 65,536 candidates. At that point the entire future of the generator is arithmetic. The program below implements precisely that attack, then points the identical code at a SecureRandom:

import java.security.SecureRandom;
import java.util.Random;

/**
 * java.util.Random is a linear congruential generator: 48-bit state,
 * state' = state * 0x5DEECE66D + 0x5, and each nextInt() is the TOP
 * 32 bits of the state after one step. Two consecutive nextInt()
 * outputs pin down the low 16 unknown bits by trial -- and then every
 * future value is a prediction.
 *
 * The same code pointed at a SecureRandom has nothing to latch onto.
 */
public class Predictable {

    // LCG constants of java.util.Random (48-bit state)
    static final long MASK = (1L << 48) - 1;
    static final long MULT = 0x5DEECE66DL;
    static final long ADD  = 0xBL;

    static long next(long state) {
        return (state * MULT + ADD) & MASK;
    }

    /**
     * nextInt() = top 32 bits of the post-step state. So the first
     * observation fixes the top 32 bits of the state, leaving 2^16
     * candidates for the low bits. The second observation selects
     * exactly one of them.
     */
    static Long recoverState(int first, int second) {
        long base = ((long) first & 0xFFFFFFFFL) << 16;
        for (long t = 0; t < (1L << 16); t++) {
            long state = base | t;
            if ((next(state) >>> 16) == ((long) second & 0xFFFFFFFFL)) {
                return state;
            }
        }
        return null;
    }

    static int topBits(long state) {
        return (int) (state >>> 16);
    }

    public static void main(String[] args) {
        // ---- point the attack at java.util.Random ----
        Random r = new Random(); // unseeded
        int a = r.nextInt();
        int b = r.nextInt();
        Long state = recoverState(a, b);
        int predicted = topBits(next(next(state)));
        int actual = r.nextInt();
        System.out.println("== java.util.Random ==");
        System.out.println("observed nextInt(): " + a + ", " + b);
        System.out.println("candidate states tried: 2^16 = " + (1L << 16));
        System.out.println("predicted next: " + predicted);
        System.out.println("actual next:    " + actual);
        System.out.println("attack " + (predicted == actual ? "SUCCEEDED" : "failed") + "\n");

        // ---- point the SAME code at a SecureRandom ----
        SecureRandom s = new SecureRandom();
        int x = s.nextInt();
        int y = s.nextInt();
        Long state2 = recoverState(x, y);
        String verdict;
        if (state2 == null) {
            verdict = "no candidate state is even consistent with the two outputs";
        } else {
            int predicted2 = topBits(next(next(state2)));
            int actual2 = s.nextInt();
            verdict = "candidate found, predicted " + predicted2
                    + " vs actual " + actual2
                    + (predicted2 == actual2 ? " (MATCH -- should be ~impossible)" : " (mismatch)");
        }
        System.out.println("== SecureRandom (" + s.getAlgorithm() + ") ==");
        System.out.println("observed nextInt(): " + x + ", " + y);
        System.out.println("candidate states tried: 2^16 = " + (1L << 16));
        System.out.println("attack result: " + verdict);
    }
}
== java.util.Random ==
observed nextInt(): -1485095365, -1002444789
candidate states tried: 2^16 = 65536
predicted next: -73167360
actual next:    -73167360
attack SUCCEEDED

== SecureRandom (NativePRNG) ==
observed nextInt(): 523966484, 832659856
candidate states tried: 2^16 = 65536
attack result: no candidate state is even consistent with the two outputs

Note that against java.util.Random this isn’t a toy approximation of a real attack — it is the attack, scoped to that generator’s exact structure, and it lands on the first try (a second run of the program succeeded the same way). The attacker learned the entire state from two numbers they could have observed anywhere the RNG’s output surfaced — a lottery pick, a “random” slot-machine award, a session id. The SecureRandom side is the same 65,536-way search finding nothing: no LCG state is even consistent with the two observed outputs. That’s the honest limit of the demo — it doesn’t prove NativePRNG is unbreakable, it shows that the specific algebra java.util.Random leaks simply isn’t there.

The decision rule the output supports: if the randomness is the security boundary (ids, nonces, keys, draws, sampling for a contest), the generator must be one whose state can’t be reconstructed from its outputs. java.util.Random fails that test in microseconds.

One instance can be shared

A practical question that comes up in review: do I need a SecureRandom per thread, per request, or is one shared instance fine? The JDK 21 Javadoc answers it in the class description — “SecureRandom objects are safe for use by multiple concurrent threads” — and the way to sanity-check that is to put load on exactly that setup. One shared instance, eight threads, 200,000 nextInt() calls each:

import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * Eight threads hammering ONE shared SecureRandom instance.
 * If it were not thread-safe, expect torn state, exceptions, or
 * duplicated values; the contract is a clean concurrent stream.
 */
public class Threads {

    static final int THREADS = 8;
    static final int CALLS = 200_000;

    public static void main(String[] args) throws Exception {
        SecureRandom shared = new SecureRandom();
        System.out.println("sharing one instance: " + shared.getAlgorithm()
                + " (provider " + shared.getProvider().getName() + ")");
        System.out.println(THREADS + " threads x " + CALLS + " nextInt() calls\n");

        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
        List<Future<long[]>> futures = new ArrayList<>();
        for (int t = 0; t < THREADS; t++) {
            final int id = t;
            futures.add(pool.submit(new Callable<long[]>() {
                public long[] call() {
                    long evens = 0, sum = 0;
                    for (int i = 0; i < CALLS; i++) {
                        int v = shared.nextInt();
                        if ((v & 1) == 0) evens++;
                        sum += v;
                    }
                    return new long[] { id, evens, sum };
                }
            }));
        }
        long totalEven = 0;
        long totalSum = 0;
        for (Future<long[]> f : futures) {
            long[] r = f.get();
            totalEven += r[1];
            totalSum += r[2];
            System.out.printf("thread %d: %d calls, %d even, checksum %d%n",
                    r[0], CALLS, r[1], r[2]);
        }
        pool.shutdown();

        long expected = (long) THREADS * CALLS / 2;
        long totalCalls = (long) THREADS * CALLS;
        double fraction = (double) totalEven / totalCalls;
        System.out.printf("%ntotal: %d calls, %d even (%.4f vs 0.5 expected), no exceptions%n",
                totalCalls, totalEven, fraction);
    }
}
sharing one instance: NativePRNG (provider SUN)
8 threads x 200000 nextInt() calls

thread 0: 200000 calls, 99986 even, checksum 200760980464
thread 1: 200000 calls, 100310 even, checksum 764051756698
thread 2: 200000 calls, 100078 even, checksum -8401249134
thread 3: 200000 calls, 99825 even, checksum 634948230079
thread 4: 200000 calls, 100153 even, checksum 1068430388261
thread 5: 200000 calls, 100498 even, checksum -37231136810
thread 6: 200000 calls, 100057 even, checksum 1169745931955
thread 7: 200000 calls, 100129 even, checksum -929301425539

total: 1600000 calls, 801036 even (0.5006 vs 0.5 expected), no exceptions

1.6 million interleaved draws, zero exceptions, and each thread’s even-count landed within a hair of 100,000 (aggregate 0.5006) — the stream behaves like one shared, undamaged sequence rather than eight crosstalk-prone ones. This matches the documented contract, so a singleton SecureRandom behind your token or nonce code is fine; there’s no ThreadLocal ritual required. (What this run doesn’t establish is performance under contention — I didn’t measure throughput, and the Javadoc notes some implementations’ nextBytes can block while gathering entropy.)

Minting tokens: the 32-byte idiom

Now the actual job: a session token. The idiom is 32 random bytes (256 bits) rendered with URL-safe Base64. UUID.randomUUID() is the same idea in a fixed shape — per the JDK 21 UUID Javadoc it’s a “type 4 (pseudo randomly generated) UUID” produced “using a cryptographically strong pseudo random number generator,” and per RFC 4122 a version-4 UUID carries 60 + 14 + 48 = 122 random bits. The program puts them side by side, checks 100,000 minted tokens for collisions, and ends on the classic under-sized alternative:

import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

/**
 * Session-style tokens from SecureRandom, side by side with UUID v4.
 */
public class Tokens {

    public static void main(String[] args) {
        SecureRandom rng = new SecureRandom();

        // 32 bytes = 256 bits of randomness, URL-safe base64, no padding
        String token = token(rng);
        System.out.println("session token (256 bits): " + token);
        System.out.println("  length: " + token.length() + " chars");
        System.out.println("  alphabet used: " + alphabet(token));
        System.out.println();

        // UUID v4 is the same idea in a fixed shape: 122 random bits
        UUID u = UUID.randomUUID();
        System.out.println("UUID v4:                  " + u);
        System.out.println("  length: " + u.toString().length() + " chars");
        System.out.println("  version: " + u.version() + "  variant: " + u.variant());
        System.out.println();

        // what uniqueness actually looks like: mint 100k tokens, look for
        // a collision. 256-bit space: none. (We only check the string
        // form, but the point is the search space.)
        Set<String> seen = new HashSet<>();
        int collisions = 0;
        for (int i = 0; i < 100_000; i++) {
            String t = token(rng);
            if (!seen.add(t)) collisions++;
        }
        System.out.println("100000 tokens minted, collisions: " + collisions);

        // contrast: the classic mistake -- a small numeric range.
        System.out.println();
        System.out.println("nextInt(1000000) examples: " + rng.nextInt(1_000_000)
                + ", " + rng.nextInt(1_000_000) + "  (only 20 bits of entropy)");
    }

    static String token(SecureRandom rng) {
        byte[] raw = new byte[32];
        rng.nextBytes(raw);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
    }

    static String alphabet(String s) {
        Set<Character> chars = new HashSet<>();
        for (char c : s.toCharArray()) chars.add(c);
        StringBuilder sb = new StringBuilder();
        for (char c : chars) sb.append(c);
        return sb.toString();
    }
}
session token (256 bits): 31GOIiOLbnAXezC33n9ZpS2WGyal1CI-cCGFR4kxwxE
  length: 43 chars
  alphabet used: ACEFGILORSWXZabceikl-np1234wx9yz

UUID v4:                  1f8a00ca-6064-4b0c-8be5-49750401c0e8
  length: 36 chars
  version: 4  variant: 2

100000 tokens minted, collisions: 0

nextInt(1000000) examples: 297872, 887212  (only 20 bits of entropy)

The token is 43 characters because 32 bytes is 256 bits and URL-safe Base64 packs 6 bits per character (256/6 = 42⅔, so 43 with no padding). The UUID came out as version 4, variant 2 — the wire format the Javadoc describes, in the shape a database column or log parser expects. Zero collisions across 100,000 mints is exactly what you’d expect from a 256-bit space — and the check matters less as a proof than as a habit: you only get to find out a collision exists when two of your own tokens collide, which at this size will be an eternity. The contrast that matters is the last line — nextInt(1_000_000) hands out at most log₂(1,000,000) ≈ 19.9 bits. If a token scheme’s entire entropy is one number below a million, an attacker’s search space is the size of a day’s frames in a video, not 2²³⁶ (≈ 10⁷¹) times larger. The right size comes from “how many guesses can the attacker try,” and 256 bits leaves room for that question to change.

Takeaway

The mental model that holds up across all five runs: SecureRandom is a stateful CSPRNG you draw from, not something you seed per use. It seeds itself once, from entropy, and then hands out output that the JDK commits — in the class Javadoc — to keeping “cryptographically strong, as described in RFC 4086.” Share the instance freely; check getAlgorithm() if you care which implementation you got; and treat any call to setSeed as a deliberate switch into deterministic fixture mode, not as extra entropy. One test decides the generator, not the documentation: if two observed outputs let you compute the third, it was never protecting anything.