Secure Random Tokens
Generate unguessable session ids, reset links and API keys with SecureRandom, and see why Random and Math.random must never produce them.
SecureRandom random = new SecureRandom(); // create once, share
byte[] bytes = new byte[32];
random.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);Session ids, password reset links, email confirmation codes, API keys and CSRF tokens all work the same way: whoever holds the value is trusted. Their whole security is that nobody else can guess them. That needs a cryptographically secure random number generator, which in Java is java.security.SecureRandom.
Why not Random?
java.util.Random, Math.random() and ThreadLocalRandom are fast generators built for simulations and shuffling. Random is a linear congruential generator with 48 bits of state: anyone who sees a few of its outputs can work out the state and predict every value that follows, including the reset token you send to the next user. Two Random objects with the same seed produce the same sequence forever. SecureRandom draws on the operating system's entropy and is designed so that past output reveals nothing about future output.
Making a token
private static final SecureRandom RANDOM = new SecureRandom();
static String newToken() {
byte[] bytes = new byte[32]; // 256 bits
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
32 bytes gives 256 bits, far beyond guessing range. URL-safe Base64 without padding turns them into 43 characters that work in URLs, headers and cookies with no escaping. Hex works too and gives 64 characters.
Create one SecureRandom and share it. It is thread-safe, and creating one per request wastes time seeding it. Prefer new SecureRandom() over SecureRandom.getInstanceStrong() on servers: the strong instance on Linux is NativePRNGBlocking, which reads /dev/random and on older kernels can stall request threads while it waits for entropy.
Storing and checking tokens
- Store a hash, not the token. Save the SHA-256 of a reset or API token with its expiry, and look it up by hashing what the client sends. A leaked table then contains nothing usable.
- Expire and use once. A reset token should live minutes, not days, and be deleted when used.
- Short codes need rate limits. A six-digit code has only a million values. It is fine for a code sent by email or SMS only when attempts are capped.
What about UUID?
UUID.randomUUID() uses a SecureRandom internally and carries 122 random bits, so it is a reasonable unguessable identifier. A purpose-built token is still clearer: you control the length, it has no fixed version digits, and nobody mistakes it for a public record id. Never use time-based or sequential ids as secrets.
Example
When to use it
- Issue a 256-bit session id in a cookie after login, generated by one shared SecureRandom.
- Email a password reset link containing a random token whose SHA-256 hash and 30-minute expiry are stored server side.
- Create API keys for customers, show them once, and keep only their hashes for authentication.
More examples
A seeded Random is fully predictable
Random's output is a pure function of its seed, so anyone who knows or guesses the seed, or recovers the state from a few outputs, gets every future code. SecureRandom gives no such handle.
void main() {
Random server = new Random(20261013); // say, seeded from the date
Random attacker = new Random(20261013); // same guess at the seed
for (int i = 0; i < 3; i++) {
String issued = "%06d".formatted(server.nextInt(1_000_000));
String predicted = "%06d".formatted(attacker.nextInt(1_000_000));
IO.println("issued " + issued + ", predicted " + predicted + ", match " + issued.equals(predicted));
}
}
// Output:
// issued 892583, predicted 892583, match true
// issued 471500, predicted 471500, match true
// issued 482361, predicted 482361, match trueSix-digit verification codes
nextInt(bound) is uniform over 0 to bound minus 1, and the %06d format keeps leading zeros so 42 becomes 000042. Short codes are only safe together with an attempt limit and a short expiry.
static final SecureRandom RANDOM = new SecureRandom();
String verificationCode() {
return "%06d".formatted(RANDOM.nextInt(1_000_000)); // 000000 to 999999
}
void main() {
boolean allSixDigits = true;
for (int i = 0; i < 10_000; i++) {
String code = verificationCode();
allSixDigits &= code.matches("\\d{6}");
}
IO.println("10000 codes, all six digits: " + allSixDigits);
IO.println("possible values: " + 1_000_000);
IO.println("chance per guess: 1 in 1000000, so cap attempts at 5");
}
// Output:
// 10000 codes, all six digits: true
// possible values: 1000000
// chance per guess: 1 in 1000000, so cap attempts at 5A password reset token stored as a hash
Only the hash and an expiry are stored, so someone reading the database cannot use pending reset links. A real service would also delete the entry once the password is changed.
static final SecureRandom RANDOM = new SecureRandom();
record ResetRequest(String email, Instant expiresAt) {}
String sha256Hex(String s) throws NoSuchAlgorithmException {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));
}
void main() throws Exception {
Map<String, ResetRequest> byTokenHash = new HashMap<>();
Instant now = Instant.parse("2026-10-13T09:00:00Z");
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); // goes in the email link
byTokenHash.put(sha256Hex(token), new ResetRequest("[email protected]", now.plus(Duration.ofMinutes(30))));
for (Instant at : List.of(now.plusSeconds(600), now.plusSeconds(3600))) {
ResetRequest r = byTokenHash.get(sha256Hex(token));
boolean valid = r != null && at.isBefore(r.expiresAt());
IO.println("at " + at + ": " + (valid ? "reset allowed for " + r.email() : "link expired"));
}
IO.println("unknown token: " + byTokenHash.containsKey(sha256Hex("made-up")));
}
// Output:
// at 2026-10-13T09:10:00Z: reset allowed for [email protected]
// at 2026-10-13T10:00:00Z: link expired
// unknown token: false
Discussion