CWE-330: Use of Insufficiently Random Values - Go
Overview
Use of insufficiently random values in Go applications occurs when predictable or weak random number generation is used for security-sensitive operations, allowing attackers to predict or reproduce supposedly random values. Go provides two distinct random number generation packages: math/rand for pseudo-random numbers (PRNG) suitable for simulations and games, and crypto/rand for cryptographically secure random numbers (CSPRNG) required for security contexts. Using math/rand for security purposes is a critical vulnerability because its output is a deterministic function of a small internal state - one that an attacker can recover from the values it has already produced, whether or not they can guess the seed.
Security-critical operations requiring cryptographically secure randomness include session token generation, password reset tokens, CSRF tokens, API keys, encryption keys and nonces, initialization vectors (IVs), salts for password hashing, and any identifier used for authentication or authorization. Predictable random values in these contexts enable session hijacking (guessing session IDs), account takeover (predicting reset tokens), CSRF attacks (reproducing CSRF tokens), and cryptographic breaks (repeating nonces in encryption).
The fundamental problem with math/rand is that it is designed for simulations, tests, and other non-security use cases, not adversarial unpredictability. Two things about it have changed and neither made it safe:
- The global source is now seeded randomly at startup. Before Go 1.20 the package-level functions behaved as though
Seed(1)had been called, so an unseeded program produced the same tokens on every run. That is fixed. rand.Seedno longer does anything. It was deprecated in Go 1.20 and, as the package documentation puts it, "as of Go 1.24Seedis a no-op". Measured on Go 1.25:rand.Seed(1)followed byrand.Intnreturned a different value each time, and onlyGODEBUG=randseednop=0restored the old behaviour.
The practical consequence is that a finding on the global math/rand functions is no longer a seed problem and cannot be triaged as one - there is no seed to guess and no start time to correlate. It is an algorithm problem: math/rand is a statistical generator, its state is recoverable from a sufficient run of its own output, and every value after that is determined. Seeding remains a live weakness in the other shape - rand.New(rand.NewSource(x)) builds a local source that still honours x in full, so a local source seeded from a timestamp or a user ID is exactly as reproducible as it looks.
Primary Defence: Use crypto/rand.Reader with io.ReadFull() for all security-sensitive random value generation. Never use math/rand for authentication tokens, encryption keys, nonces, or any security-critical purpose. Ensure random values have sufficient entropy (128+ bits for tokens, 256 bits for encryption keys).
Common Vulnerable Patterns
math/rand for Session Tokens
// VULNERABLE - Predictable session token generation
package main
import (
"math/rand"
"time"
)
// DANGEROUS: a local source honours its seed in full, unlike the
// package-level rand.Seed, which has been a no-op since Go 1.24
var tokenRNG = rand.New(rand.NewSource(time.Now().UnixNano()))
func generateSessionToken() string {
// VULNERABLE - math/rand is deterministic
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
token := make([]byte, 32)
for i := range token {
token[i] = charset[tokenRNG.Intn(len(charset))]
}
return string(token)
}
// ATTACK:
// Attacker knows the process started around 2024-02-06 14:30:00
// Tests seeds from time.Now().UnixNano() across that window
// Reproduces the PRNG sequence to predict every session token the process issues
Why this is vulnerable: rand.New(rand.NewSource(seed)) builds a local source that is fully determined by seed - this is the seeding shape that still works, and the reason a page written against rand.Seed needs re-reading rather than re-labelling. time.Now().UnixNano() looks like 64 bits and is not: it is a wall-clock reading an attacker can bracket to the second from an HTTP Date header or the process start time, leaving about 2^30 nanosecond values to test offline. Each candidate is one cheap replay of the same 32-character loop. Use crypto/rand for session tokens; there is no seed you can pick that makes math/rand safe.
Weak Password Reset Tokens
// VULNERABLE - Predictable reset tokens
import (
"fmt"
"math/rand"
"time"
)
func generateResetToken(userID int) string {
// DANGEROUS: Seeding a local source with user data
rng := rand.New(rand.NewSource(int64(userID) + time.Now().Unix()))
// 6-digit code
code := rng.Intn(1000000)
return fmt.Sprintf("%06d", code)
}
// ATTACK:
// Attacker knows their user ID and approximate request time
// Computes possible seeds and corresponding codes
// Tests a small number of 6-digit codes to take over any account
Why this is vulnerable: A 6-digit code has only 1 million possible values, small enough to guess by brute force on its own. Seeding with userID + timestamp removes even that work: user IDs are often sequential, and the seconds in a 10-minute window around the request are a short list to test. The attacker computes the reset code for any account and takes it over without ever receiving the reset email.
Time-Based Seeds with Known Timing
// VULNERABLE - Time-seeded randomness for security
import (
"math/rand"
"time"
)
type Game struct {
rng *rand.Rand
secretValue int
}
func NewGame() *Game {
// VULNERABLE - seeded when the game starts, from an observable clock
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
return &Game{
rng: rng,
secretValue: rng.Intn(1000000),
}
}
// Attacker connects, notes the connection time from the response,
// replicates the seed from that window,
// and predicts every subsequent draw from this game's source
Why this is vulnerable: The seeding moment is the one thing the attacker is guaranteed to observe - they caused it. A game created on connection is seeded within a round trip of a timestamp the client already holds, which turns a nominally 64-bit seed into a search over a few hundred million nanosecond values, each verifiable against the first outcome the player sees. Every later draw from that source then follows. Note that the seed is not the only thing worth removing here: even seeded unpredictably, math/rand's state is recoverable from enough of its own output, so anything an adversary is paid to predict belongs on crypto/rand.
Using math/rand for Encryption Nonces
// VULNERABLE - Predictable nonces break encryption
import (
"crypto/aes"
"crypto/cipher"
"math/rand"
)
func encryptData(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// DANGEROUS: math/rand for nonce generation
nonce := make([]byte, gcm.NonceSize())
for i := range nonce {
nonce[i] = byte(rand.Intn(256))
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
return append(nonce, ciphertext...), nil
}
// VULNERABILITY:
// The nonce is drawn from a generator whose future output an attacker can
// compute, and GCM needs its nonces to be unused, not merely unrepeated here.
Why this is vulnerable: GCM requires a nonce never reused under one key, and this construction gives an attacker two ways in. math/rand's state is recoverable from a run of its own output, and the global source is shared process-wide, so any other endpoint that returns math/rand values - a request ID, a jitter value, a shuffled result set - hands over the material to reconstruct it and compute the nonces the encryption path will use next. Second, a process that restores state from a checkpoint, forks after seeding, or runs several replicas from one snapshot replays the same sequence in each. Nonce reuse in GCM is not a degradation: two messages under one key and nonce leak the XOR of their plaintexts and expose the GHASH authentication key, which lets an attacker forge tags for messages you never sent. Note that byte(rand.Intn(256)) is also 12 separate draws where one io.ReadFull would do.
Sequential or Guessable API Keys
// VULNERABLE - Sequential API key generation
import (
"fmt"
"sync"
)
var (
keyCounter int
counterMu sync.Mutex
)
func generateAPIKey() string {
counterMu.Lock()
keyCounter++
id := keyCounter
counterMu.Unlock()
// VULNERABLE - Sequential, predictable keys
return fmt.Sprintf("API-KEY-%08d", id)
}
// ATTACK:
// Attacker receives API-KEY-00012345
// Knows other valid keys are nearby: 00012344, 00012346, etc.
// Can enumerate and test all keys in range
Why this is vulnerable: An attacker holding API-KEY-00012345 knows the neighbouring keys are 00012344 and 00012346, so one issued key is enough to enumerate the others and make authenticated calls with them. Drawing the counter from math/rand instead does not help, because that sequence is predictable too. API keys must be cryptographically random with high entropy (128-256 bits) to resist enumeration. Encoding the counter in base64 changes how the key looks without adding entropy to it.
Secure Patterns
Cryptographically Secure Session Tokens
// SECURE - Session tokens with crypto/rand
package main
import (
"crypto/rand"
"encoding/base64"
"io"
)
func generateSecureSessionToken() (string, error) {
// SECURE - 32 bytes = 256 bits of entropy
bytes := make([]byte, 32)
// SECURE - crypto/rand provides cryptographically secure randomness
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return "", err
}
// Encode to string: RawURLEncoding, not URLEncoding, so no '=' padding
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// Usage
func createSession(userID string) (string, error) {
token, err := generateSecureSessionToken()
if err != nil {
return "", err
}
// Store session in database/cache
storeSession(token, userID)
return token, nil
}
func storeSession(token, userID string) {
// Implementation: Redis, database, etc.
}
Why this works: crypto/rand.Reader is a cryptographically secure random number generator (CSPRNG) that reads from operating-system cryptographic random APIs. It's designed to be unpredictable even to attackers who can observe some outputs. 32 bytes gives 2^256 possible tokens, which puts guessing one out of reach. io.ReadFull ensures the entire buffer is filled with random data, failing if sufficient randomness is not available. base64.RawURLEncoding gives a 43-character token that is safe in a cookie value, a path segment or a query parameter - the padded base64.URLEncoding would append =, which is a reserved character in a query string and has to be escaped on the way out and unescaped on the way back, a round trip that is easy to get half right.
Secure Password Reset Tokens
// SECURE - Unpredictable reset tokens with expiration
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"time"
)
type ResetToken struct {
Token string
UserID string
ExpiresAt time.Time
}
func generatePasswordResetToken(userID string) (*ResetToken, error) {
// SECURE - 32 bytes = 256-bit token
bytes := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return nil, err
}
token := hex.EncodeToString(bytes)
resetToken := &ResetToken{
Token: token,
UserID: userID,
ExpiresAt: time.Now().Add(15 * time.Minute), // 15-minute expiry
}
// Store in database with expiry
storeResetToken(resetToken)
return resetToken, nil
}
func validateResetToken(token string) (string, error) {
// Retrieve from database
resetToken := getResetToken(token)
if resetToken == nil {
return "", fmt.Errorf("invalid token")
}
// Check expiration
if time.Now().After(resetToken.ExpiresAt) {
return "", fmt.Errorf("token expired")
}
// Delete token (single use)
deleteResetToken(token)
return resetToken.UserID, nil
}
func storeResetToken(token *ResetToken) {
// Implementation: Database with TTL
}
func getResetToken(token string) *ResetToken {
// Implementation: Database lookup
return nil
}
func deleteResetToken(token string) {
// Implementation: Delete from database
}
Why this works: 256-bit tokens provide 2^256 possible values, making brute-force attacks computationally infeasible. crypto/rand ensures each token is unpredictable and unique. Hex encoding (64 characters) makes tokens safe for URLs and emails. Time-based expiration limits the attack window - a token is valid for 15 minutes. Single-use tokens (deleted after validation) prevent replay attacks. Storing tokens server-side with user association prevents token manipulation.
Secure API Key Generation
// SECURE - Cryptographically random API keys
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"time"
)
type APIKey struct {
Key string // Public key given to user
Hash string // Hashed key stored in database
CreatedAt time.Time
}
func generateAPIKey() (*APIKey, error) {
// SECURE - 32 bytes of entropy
bytes := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return nil, err
}
// Format: "sk_" prefix + base64 key
key := "sk_" + base64.RawURLEncoding.EncodeToString(bytes)
// SECURE - Hash for storage (don't store plaintext keys)
hash := sha256.Sum256([]byte(key))
hashStr := base64.URLEncoding.EncodeToString(hash[:])
return &APIKey{
Key: key,
Hash: hashStr,
CreatedAt: time.Now(),
}, nil
}
func validateAPIKey(providedKey string) (bool, error) {
// Hash the provided key
hash := sha256.Sum256([]byte(providedKey))
hashStr := base64.URLEncoding.EncodeToString(hash[:])
// Look up in database by hash
exists := checkAPIKeyHash(hashStr)
return exists, nil
}
func checkAPIKeyHash(hash string) bool {
// Implementation: Database lookup
return false
}
Why this works: API keys have 256 bits of entropy from crypto/rand, making enumeration impossible. The "sk_" prefix identifies the key type (secret key), helping detect accidental exposure in logs or repositories. Keys are hashed before database storage using SHA-256, so database compromise doesn't expose the actual keys (similar to password hashing). Validation requires hashing the provided key and comparing to stored hashes. Base64 URL encoding creates readable, URL-safe strings. This pattern is used by services like Stripe, OpenAI, and GitHub.
Secure Random Values for Cryptography
// SECURE - Random keys and nonces for AES-GCM
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"io"
)
func generateEncryptionKey() ([]byte, error) {
// SECURE - 32 bytes for AES-256
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
return key, nil
}
func encryptWithSecureNonce(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// SECURE - Cryptographically random nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// Encrypt
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
Why this works: crypto/rand provides unpredictable, high-entropy random values suitable for cryptographic operations. Keys generated with crypto/rand are not reproducible by attackers. AES-GCM nonces must be unique for each encryption under the same key; random nonces from crypto/rand make reuse negligibly likely when used correctly. Using io.ReadFull guarantees the buffer is completely filled with random data or returns an error.
Testing with Deterministic Randomness
// SECURE - Using math/rand safely in tests
package main_test
import (
"math/rand"
"testing"
)
func TestRandomDataProcessing(t *testing.T) {
// SECURE - math/rand is fine for test data generation
// Not used for security, just test reproducibility
// A local source is the only way left to get a reproducible sequence:
// rand.Seed was deprecated in Go 1.20 and is a no-op as of Go 1.24
r := rand.New(rand.NewSource(42))
testData := make([]int, 100)
for i := range testData {
testData[i] = r.Intn(1000)
}
// Test business logic with random data
result := processData(testData)
// Assert results
if result < 0 {
t.Errorf("Unexpected result: %d", result)
}
}
func processData(data []int) int {
// Business logic
return 0
}
// IMPORTANT: Never use math/rand in production security code
// This pattern is ONLY safe for testing, simulations, games
Why this works: math/rand is appropriate for test data generation, where reproducibility is what you want and security does not apply. The fixed seed 42 makes the test deterministic - the same 100 values every run, so a failure can be reproduced from the seed alone. Tests don't handle real user data or credentials. Production code must never use math/rand for security; this is the separation to hold to, crypto/rand for security and math/rand for simulations and tests.
Secure Random Integers in Range
// SECURE - Unbiased random integers from crypto/rand
import (
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"math/big"
)
// Method 1: Using crypto/rand with math/big (preferred for ranges)
func secureRandomInt(max int64) (int64, error) {
// SECURE - Cryptographically secure random in range [0, max)
n, err := rand.Int(rand.Reader, big.NewInt(max))
if err != nil {
return 0, err
}
return n.Int64(), nil
}
// Method 2: Generate random uint64
func secureRandomUint64() (uint64, error) {
var bytes [8]byte
if _, err := io.ReadFull(rand.Reader, bytes[:]); err != nil {
return 0, err
}
return binary.BigEndian.Uint64(bytes[:]), nil
}
// Usage example: Secure random selection from slice
func selectRandomItem(items []string) (string, error) {
if len(items) == 0 {
return "", fmt.Errorf("empty slice")
}
idx, err := secureRandomInt(int64(len(items)))
if err != nil {
return "", err
}
return items[idx], nil
}
Why this works: rand.Int(rand.Reader, max) generates cryptographically secure random integers in a range without modulo bias. It reads from crypto/rand and uses rejection sampling to ensure uniform distribution. For uint64 values, reading 8 bytes from crypto/rand and converting with binary.BigEndian.Uint64 provides full 64-bit entropy. These patterns suit any random selection whose outcome an attacker must not be able to predict, such as picking a record at random from a database. Unlike math/rand.Intn(), they draw from a CSPRNG.
Considerations
Ask what guessing the value would get someone. Randomness has non-security
uses everywhere - sampling, shuffling, jitter, cache-busting, test fixtures -
and none of them need a CSPRNG. The finding is material when the value is a
session identifier, a token, a key, an OTP, a salt, an IV, or anything else
whose unpredictability is what makes it work. If it is not, math/rand is the
correct choice and the finding should be closed with the reason recorded.
Do not blanket-replace. crypto/rand goes to the kernel and is meaningfully
slower than math/rand. That cost is irrelevant for a handful of tokens per
request and very relevant in a simulation or a loop generating millions of
values. Replacing every call site to make a scanner quiet trades real throughput
for no security benefit, and it makes the genuine findings harder to see.
Check the Go version before triaging a seeding finding. The same source file
means different things on either side of Go 1.24: before it, rand.Seed set the
global source and a timestamp seed was the whole vulnerability; after it, the
call is inert and the weakness is the algorithm. rand.New(rand.NewSource(x))
is unaffected by that change and is where a real seeding weakness lives now.
crypto/rand.Read no longer returns an error worth branching on. Its
documentation now reads "It never returns an error, and always fills b
entirely" - it calls io.ReadFull on Reader and crashes the program
irrecoverably rather than handing back a short read, because a process that
cannot obtain randomness has no safe way to continue. Keep the err check, both
for older toolchains and because io.ReadFull(rand.Reader, ...) returns one
anyway; the point is that the error branch has nothing useful to do. If you find
one falling back to math/rand, that is the finding.
Anything derived from a weak value stays weak. Hashing it, base64-encoding it, concatenating a timestamp, or truncating it changes how the output looks without adding entropy - the result is still fully determined by the predictable input. There is no post-processing that fixes the source; only replacing the generator does.
Check the length once the generator is right. This CWE is about the
unpredictability of the value, which depends on both the source and how much of
it you take. Four bytes from crypto/rand is still only 32 bits. Use at least
16 bytes for tokens and 32 for key material, and remember hex encoding doubles
the character count, which is where half the intended entropy usually goes
missing.
Common Pitfalls
- Crypto-seeding a non-crypto PRNG: Reading a seed from
crypto/randand passing it torand.NewSource(), then continuing to draw values fromIntn()/Int63()for the actual token - a high-entropy seed only obscures the starting point of a deterministic algorithm. Once an attacker observes enough output,math/rand's internal state (and every past and future value) can be reconstructed. Draw every security-relevant byte directly fromcrypto/rand, not just the seed. The same fix routed throughmath/rand.Seed()is worse than ineffective: on Go 1.24 and later that call does nothing at all, so the reviewer sees the entropy being sourced correctly and the generator never receives it. - Reading a
rand.Seedcall as evidence of anything: Arand.Seed(time.Now().UnixNano())line in existing code is now dead, so neither its presence nor its removal changes the generated values. Triage the draw - which package the token comes from - rather than the seeding line above it, and check the Go version ingo.mod, because the same source file behaves differently on Go 1.19, 1.23 and 1.24. - Assuming
math/rand/v2is safe because it's newer: Go 1.22 introducedmath/rand/v2with an improved API and algorithm, but its documentation carries the same warning as the original package: it is not a CSPRNG and remains unsuitable for security-sensitive values. Onlycrypto/randqualifies, regardless of package version.