Why is math/rand/v2 unsafe for generating a password-reset token in Go, and what do you use instead?
answer
- Go ships two random packages
- one is for simulations, one for secrets
- seeding is not what makes the difference
- one of them reads the operating system
- the token call lives in crypto/rand
basics
~20 smath/rand/v2 is a statistical generator that makes no unpredictability promise, so its output can be reconstructed no matter how it is seeded. Use crypto/rand instead: rand.Text() for a token string, or crypto/rand.Read to fill a byte slice.
solid answer
~40 sGo ships two random packages and only one of them is for secrets. `math/rand/v2` exists for simulations, sampling, jitter and shuffling; its documentation says outright that its output may be predictable and points you at `crypto/rand` for anything security-sensitive. Seeding is a red herring: `math/rand/v2` has no top-level `Seed` at all and its global functions are already randomly seeded, yet the package still gives no guarantee, and its API hands out raw generator output that lets an observer recover the stream. `crypto/rand` reads the operating system's cryptographic generator — `getrandom` on Linux, `arc4random_buf` on macOS — which is the right source for a reset link. In practice: `token := rand.Text()` gives you a 26-character token in one call, and `crypto/rand.Read(b)` fills raw key bytes.
code
go · 15 linesimport (
crand "crypto/rand"
mrand "math/rand/v2"
"strconv"
)
// Wrong: predictable output, however the generator was seeded.
func badToken() string {
return strconv.FormatUint(mrand.Uint64(), 36)
}
// Right: drawn from the operating system's cryptographic generator.
func goodToken() string {
return crand.Text()
}go deeper
Recall that Go has two random packages and that crypto/rand is the one for anything a user should not be able to guess. Be able to name crypto/rand.Text() or crypto/rand.Read as the call you would actually write.
Explain why the seeding argument is irrelevant: a statistical generator promises a distribution, not unpredictability, and its own documentation says so. Know that math/rand/v2 has no top-level Seed and that automatic seeding changed nothing about security.
Show how you keep this from recurring: separate the package that mints secrets from the one that adds jitter, check imports in review, and treat any generator seeded from a configuration value on the secret path as a defect to fix and disclose.
Frame it as a boundary the team should not have to relitigate. Decide where secret generation lives, make the wrong import mechanically visible, and be able to say what you would do about credentials already issued if the wrong package ever shipped.
## Two packages, two jobs Go's standard library has two random-number packages, and the whole question is knowing which one you are importing. - `math/rand` and its successor `math/rand/v2` are **statistical** generators. They are fast, they produce well-distributed numbers, and they are built for simulations, load jitter, random sampling, shuffling a slice, and picking a test fixture. Their documentation states plainly that the output may be easily predictable regardless of how the generator is seeded, and refers you elsewhere for security-sensitive work. - `crypto/rand` is the **cryptographic** source. It reads from the operating system's kernel generator (`getrandom` on Linux, `arc4random_buf` on macOS and the BSDs, the platform equivalent on Windows). This is the package you use for tokens, session identifiers, password-reset links, salts, nonces and keys. ## Why "but I seed it properly" is the wrong argument Engineers arriving from other languages usually carry a habit: seed the generator at startup from the clock so it is not the same every run. In Go that habit is doubly out of date. Since Go 1.20 the top-level `math/rand` functions are seeded from a random value at program start, and `rand.Seed` is deprecated. `math/rand/v2` went further and has no top-level `Seed` function at all — you either use the automatically seeded global functions or you construct a generator explicitly with `rand.NewPCG(seed1, seed2)` or `rand.NewChaCha8(seed)` precisely *because* you want reproducibility. So the "unseeded generator" bug the habit protects against does not exist any more, and it was never the reason the package is unsuitable for secrets. The reason is the contract. A statistical generator promises a distribution; a cryptographic generator promises that seeing past output does not let anyone compute future output. `math/rand/v2` only makes the first promise. The API itself works against the second: `Uint64` hands you the generator's output directly, and the generator's internal state is small enough that a handful of observed values is enough to pin it down. If your tokens are formatted generator output, an attacker who legitimately requests a few reset links for their own account has seen the stream and can compute the next ones — someone else's. A common follow-on argument is that the `math/rand/v2` global generator happens to be built on ChaCha8, a real cryptographic primitive, so it must be fine. It is not fine, for two reasons. First, that is an implementation detail with no promise attached; the package's contract is what you can rely on, and its contract explicitly disclaims security. Second, the same package exports `NewPCG` and `New`, so the reader of your code cannot tell from `rand.Uint64()` which generator is behind it. Correctness that depends on which implementation the runtime picked today is not correctness. ## What to write instead For a token string, `crypto/rand.Text()` is a single call that returns a 26-character string from the base32 alphabet with at least 128 bits of randomness, and it cannot fail. For raw bytes — a key, a salt — `crypto/rand.Read(b)` fills the slice. For a bounded integer, such as a numeric confirmation code, `crypto/rand.Int(rand.Reader, big.NewInt(1000000))` returns a uniform value in `[0, 1000000)`. None of these is slow enough to matter for per-request work. The "crypto/rand is expensive, so I will use it to seed a fast generator" pattern is a widespread mistake: the fast generator you seeded is still a statistical generator, and its output is still recoverable from a few samples. Seeding a weak generator from a strong one does not upgrade it. ## How this shows up in review The cheapest check is the import block. Any file that mints secrets and imports `math/rand` or `math/rand/v2` needs a second look — and the reverse is fine: a file that shuffles a work queue or adds retry jitter *should* be using `math/rand/v2`, because `crypto/rand` there is just slower with no benefit. Keeping the two uses in separate packages makes the rule mechanical: the package that issues reset links imports `crypto/rand` and nothing else random.
- Does seeding math/rand/v2 from crypto/rand make it safe for tokens?No. The seed is only the starting point; the generator's contract still says nothing about unpredictability, and its output reveals its state. An observer who collects a few values can compute the rest of the stream no matter where it started. Seeding a statistical generator from a cryptographic one buys you nothing except a false sense of having done something.
- The math/rand/v2 global generator is backed by ChaCha8, a real cipher. Why is that still not enough?Because it is an implementation detail with no promise behind it, and the same package also exports PCG generators. A reader of `rand.Uint64()` cannot tell which one is running, and the package documentation explicitly disclaims security use. Relying on it means your security depends on a choice the standard library never committed to keeping.
- Where is math/rand/v2 still the right call in a service like this?Retry and backoff jitter, sampling a fraction of requests for logging, shuffling a work queue, generating non-secret test data. It is faster than `crypto/rand` and needs no error handling. The rule is about what the value protects: if guessing it grants access to something, it belongs to `crypto/rand`.
A well-shuffled deck and a locked safe both give you something you cannot guess by accident. Only the safe is built to resist someone who is standing there watching you open it.
saying these in an interview costs you the question
- Says seeding with time.Now().UnixNano() makes it secure
- Thinks math/rand is fine now that Go seeds it automatically
- Treats the ChaCha8 implementation detail as a guarantee
- Hashes math/rand output with SHA-256 to make it unpredictable
- Seeds a fast generator from crypto/rand for performance
- Assumes crypto/rand is too slow for per-request tokens