Go's crypto/rand.Text and rand.Read: Why Seeded Tokens Get Guessed
Here is a function I have seen, in various costumes, in more code reviews than I would like:
// mrand is the non-cryptographic rand package from the standard library.
func newToken() string {
r := mrand.New(mrand.NewSource(time.Now().UnixNano()))
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 32)
for i := range b {
b[i] = alphabet[r.Intn(len(alphabet))]
}
return string(b)
}
It produces 32 characters from a 36-letter alphabet. That is roughly 165 bits of apparent entropy, which looks fine on a spreadsheet. In practice the token has closer to 30 bits of entropy, and an attacker with a rough idea of when it was issued can walk through them in seconds.
Where the entropy actually went
A pseudo-random generator is a deterministic function of its seed. The output length tells you nothing about how much unpredictability went in; the seed does. Here the seed is a nanosecond timestamp. If someone knows a token was minted within, say, the same second as a password-reset email they triggered, that is at most a billion candidates, and the timestamps of an HTTP response's Date header narrow it further.
Actually, it is worse than that, and this bit is interesting. The older NewSource generator reduces its 64-bit seed modulo 2^31-1 before using it. So even a "well seeded" 64-bit value collapses to about 31 bits of state selection. You could enumerate every possible generator in this family on a laptop. The generator has a large internal state (607 words) but that state is filled from the tiny seed, so it adds no secrecy.
The newer v2 version of the package improves matters for casual use: the top-level functions are randomly seeded, and there are PCG and ChaCha8 sources. ChaCha8 is a genuinely decent design. But the documentation is clear that the package as a whole is not for security-sensitive work, and the moment you call NewPCG(seed1, seed2) or NewChaCha8(seed) with something you chose, the guessability is back to whatever you chose. Also, a generator whose output you can observe (say, several tokens issued to your own account) may let you recover its state and predict the next ones. PCG in particular is not designed to resist that.
What to use instead
Go 1.24 added crypto/rand.Text, which is the function I wish had existed a decade ago:
import "crypto/rand"
func newToken() string {
return rand.Text()
}
It returns a string of 26 characters drawn from the standard base32 alphabet (A to Z and 2 to 7), which is 130 bits of encoding carrying 128 bits of randomness, straight from the operating system's CSPRNG. No alphabet to define, no modulo bias to worry about, no error to handle. It is safe in URLs, cookies and file names, and it does not depend on case or on characters that look alike in the way base64 sometimes does.
If you need raw bytes (a key, a nonce, a binary session ID) there is rand.Read:
key := make([]byte, 32)
rand.Read(key) // fills key; see below about the error
Historically people wrote if _, err := rand.Read(key); err != nil { ... }, and that still compiles. Since Go 1.24, though, Read is documented to crash the program irrecoverably if the underlying reader fails, rather than hand you an error you might ignore. The default reader uses operating system calls documented never to fail on anything but some legacy Linux systems, so in practice you are trading an error path nobody tested for a loud failure that nobody can miss. That is the right trade for key material: continuing with a half-filled or zeroed buffer is far worse than dying.
One caveat: if a program replaces rand.Reader (some tests do, to get reproducible output) then Read uses that reader. Do not do this in production code, and do not leave it set in a test binary that also mints real secrets.
Checking what you have already shipped
Finding the problem in an existing codebase is mostly a matter of grepping for imports of the non-crypto rand package in files that also mention words like token, session, reset, nonce, key, salt or secret. Two extra tells:
- Any use of
time.Now()as a seed. That is a red flag in itself, whichever generator is on the other end. - A hand-rolled alphabet indexed with
Intn. If you do need a custom alphabet with the secure generator, userand.Intwith abig.Intbound, orrand.Textplus a proper encoding step, rather thanb[i] % len(alphabet). Modulo on a random byte is biased whenever the alphabet length does not divide 256, and 36 does not.
Static analysers help here: gosec flags the non-crypto package (rule G404) and it is worth wiring into CI, though it will also flag harmless uses like shuffling a test fixture, so expect to annotate a few.
Rotating what was already issued
Replacing the generator fixes new tokens only. Anything already issued from the weak generator should be treated as guessable: long-lived API keys, unexpired password-reset tokens, persistent login cookies. Invalidate them and reissue. Short-lived tokens that have already expired do not need the same urgency, though it is worth checking the logs for bursts of failed lookups against your token endpoints, since guessing a timestamp-derived value looks exactly like that.
Where non-cryptographic randomness is fine: jitter on retries, sampling, shuffling a playlist, load-test data. The rule I use is simple. If a wrong guess by an attacker would be worth their time, the value comes from crypto/rand. Otherwise use whichever is convenient, and do not feel guilty about it.