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.

SyntaxSecureRandom 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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 true

Six-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.

Example · java
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 5

A 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.

Example · java
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

  • Be the first to comment on this lesson.