A password reset token, a session cookie, an API key, a nonce for an AES-GCM cipher -- all of them depend on one property: the attacker cannot guess the next value. In Go, that property hinges entirely on which package generated the bytes. math/rand produces a deterministic sequence from a 63-bit (or, before Go 1.20, often hardcoded) seed; anyone who recovers or guesses that seed can reproduce every value the generator will ever emit. crypto/rand instead pulls entropy from the operating system's CSPRNG -- getrandom(2) on Linux, BCryptGenRandom on Windows, getentropy(2) on macOS/BSD. Static analysis tools like gosec flag the wrong choice under rule G404, and it maps directly to CWE-338 in the CWE Top 25. This post breaks down why the distinction matters, what Go 1.20 through 1.24 changed, and how to generate secrets correctly.
Why is math/rand dangerous for security-sensitive Go code?
math/rand is dangerous because it is a pseudo-random number generator (PRNG) built for statistical simulations, not secrecy -- its output is a deterministic function of its seed, and the Go documentation says so explicitly: "top-level functions... are not suitable for security-sensitive work." Before Go 1.20 (released February 2023), if a program never called rand.Seed(), the global source defaulted to a fixed seed of 1. That meant two processes started at different times, on different machines, with no explicit seeding, would produce the identical sequence of "random" numbers -- an attacker who knew a program used math/rand for a token could often precompute the entire output stream offline. Even after Go 1.20 made the global generator auto-seed from OS entropy at startup, the underlying algorithm (an additive lagged Fibonacci-style generator) is still not cryptographically secure: observing a handful of outputs is enough to reconstruct the internal state and predict every future value, because the generator has no one-way function protecting its state transition. This is exactly the failure mode described by CWE-338 (Use of Cryptographically Weak PRNG) and CWE-330 (Use of Insufficiently Random Values), both of which sit inside the broader OWASP "Cryptographic Failures" category (A02:2021). In practice this shows up as predictable password-reset links, guessable session IDs, colliding UUIDs, and API keys that can be brute-forced far faster than their nominal bit length suggests.
How does crypto/rand actually generate randomness under the hood?
crypto/rand generates randomness by reading directly from the host operating system's cryptographically secure entropy source rather than computing values from a seed in Go code. On Linux, rand.Reader is backed by the getrandom(2) syscall (falling back to reading /dev/urandom when the syscall is unavailable); on Windows it calls BCryptGenRandom; on macOS and the BSDs it uses getentropy(2) or /dev/urandom. These are the same kernel-level generators that back OpenSSL, libsodium, and the OS's own key-generation routines, seeded from hardware interrupts, timing jitter, and dedicated hardware RNGs (RDRAND on modern x86 CPUs). Because the entropy comes from outside the Go process's own state, there is no internal seed for an attacker to recover by observing output -- reversing the stream would require compromising the kernel's entropy pool itself. The tradeoff is performance: crypto/rand.Read is meaningfully slower than math/rand because every call may cross into kernel space, which is exactly why teams are tempted to reach for the faster, insecure package inside a hot loop that also happens to generate a session token.
What changed in Go's randomness APIs between versions 1.20 and 1.24?
Three releases meaningfully reshaped this landscape between February 2023 and February 2025. Go 1.20 (February 2023) removed the fixed default seed of 1 for the global math/rand source, auto-seeding it with OS randomness at program startup -- closing the "every unseeded process is identical" failure mode, though the generator remained non-cryptographic. Go 1.22 (February 2024) introduced math/rand/v2, a rewritten package offering the ChaCha8 and PCG algorithms with better statistical properties and a cleaner API, but it is still explicitly documented as unsuitable for security use -- the "v2" branding refers to API design, not cryptographic strength, and this is a common point of confusion for teams that assume a version bump implies a security fix. Go 1.24 (February 2025) went further on the crypto/rand side: it added rand.Text(), a new function that returns a cryptographically secure, base32-encoded random string sized for direct use as a token or API key, and it changed rand.Read to never return an error in practice -- instead of silently returning zeroed or partial output on a system failure, the runtime now treats an inability to reach the OS CSPRNG as fatal and panics. That second change closes a real historical bug class: code that called crypto/rand.Read(buf) and ignored the returned error (a pattern gosec also flags) could previously ship a key generated from a partially-empty buffer on a broken system.
How do you correctly generate secure tokens, keys, and nonces in Go?
You generate them correctly by calling crypto/rand.Read or crypto/rand.Text() and never falling back to math/rand for anything that gates access or secrecy. For a 256-bit API key encoded as hex:
func GenerateAPIKey() (string, error) {
buf := make([]byte, 32) // 256 bits
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
On Go 1.24+, the equivalent for a URL-safe token is a single call: token := rand.Text(), which returns a 26-character base32 string carrying roughly 128 bits of entropy -- enough that brute-forcing it is infeasible even at billions of guesses per second. For AES-GCM nonces, the standard library's own examples in crypto/cipher use io.ReadFull(rand.Reader, nonce) rather than rand.Read directly, because ReadFull guarantees the buffer is completely filled or returns an error, which matters when a nonce is reused: reusing a single AES-GCM nonce with the same key breaks confidentiality and authentication for both messages. The rule of thumb worth enforcing in code review and CI: any variable named token, secret, key, nonce, salt, or sessionID should trace back to crypto/rand, and a gosec -include=G404 scan (or an equivalent semgrep rule) in the CI pipeline should fail the build the moment math/rand touches one of those code paths.
How does weak randomness slip into a supply chain instead of first-party code?
It slips in most often through a transitive dependency that a team never audited line-by-line, because go.mod pins a version number, not a guarantee about the algorithms used inside it. A small utility library vendored for CSV parsing or template rendering can also contain a "generate a random ID" helper that quietly uses math/rand/v2 or the pre-1.20 unseeded global source, and that helper can end up wired into a session-token generator three layers up the call graph without anyone reviewing the actual RNG call. Go's module system does not flag this at go get time, and standard SCA tooling that only checks for known CVEs by package version misses it entirely, because "uses a weak PRNG" is a code-pattern problem, not a version-pinned vulnerability with a CVE ID. This is precisely the gap between "the dependency has no published CVE" and "the dependency is safe to use for security-sensitive operations" -- and it is why source-level scanning across the full dependency tree, not just advisory-database lookups, is necessary to catch it.
How Safeguard Helps
Safeguard treats insecure randomness as a supply-chain-scale problem rather than a single-file linting concern. Our SAST scanning runs gosec-class rules -- including the G404 weak-RNG check -- not only against your first-party Go services but across every module pulled into your build graph, so a math/rand-based token generator buried three dependencies deep surfaces in the same report as a vulnerability in your own handlers. Because Safeguard maps your full software bill of materials (SBOM) alongside build provenance, we can tell you exactly which artifacts, container images, and deployed services inherited a weak-RNG code path, not just which go.mod line introduced it -- so remediation is scoped to what's actually running in production, not a theoretical dependency tree. For teams tracking Go's evolving randomness APIs, Safeguard's policy engine can enforce version-aware rules, such as requiring crypto/rand.Text() on Go 1.24+ or flagging any crypto/rand.Read call site missing error handling on earlier toolchains, directly in CI before the code merges. That turns a subtle, hard-to-audit class of bug -- one that produces no crash, no failed test, and no CVE until an attacker demonstrates it -- into a finding you catch at pull-request time, with the same rigor Safeguard applies to dependency provenance and build integrity across the rest of your supply chain.