How to Use crypto/rand for Secure Random Numbers in Go

Generate secure random bytes in Go using the crypto/rand package's Read function for cryptographic safety.

The password reset that wasn't random

You're building a password reset flow. The user clicks "Forgot Password," and your server needs to generate a unique code to email them. You grab math/rand, generate a 6-digit number, and ship it. It works. Until someone notices the codes follow a pattern. Or worse, the server restarts, the seed resets, and an attacker can reproduce the sequence. Secure randomness isn't a luxury. It's the difference between a locked door and a sticker that says "Locked."

Pseudo-random versus cryptographic random

math/rand uses a pseudo-random number generator. It starts with a seed and runs a math formula to produce a sequence that looks random. If you know the seed and the formula, you can predict every number in the sequence. Think of it like a shuffled deck of cards. Once you know the shuffle order, you know exactly which card comes next.

crypto/rand taps into the operating system's entropy pool. This pool collects noise from hardware interrupts, disk timings, and mouse movements. The result is unpredictable data that an attacker cannot reproduce, even if they have full access to your source code and previous outputs. Think of it like rolling dice that are influenced by the vibration of the table, the air pressure, and cosmic rays. You can't predict the next roll, even if you know every previous roll.

Pseudo-random is a pattern. Cryptographic random is noise. Choose noise for secrets.

The minimal example

Here's the simplest way to get secure random bytes. Allocate a slice, call Read, and handle the error.

package main

import (
	"crypto/rand"
	"fmt"
)

func main() {
	// Allocate a slice to hold 16 bytes of random data.
	// 16 bytes gives 128 bits of entropy, enough for most tokens.
	b := make([]byte, 16)

	// Read fills the slice with random bytes from the OS entropy source.
	// It blocks until enough entropy is available.
	_, err := rand.Read(b)
	if err != nil {
		// Read only fails if the OS entropy source is exhausted or broken.
		// This is rare on modern systems but fatal if it happens.
		panic(err)
	}

	// Print the bytes as a hex string for human readability.
	fmt.Printf("%x\n", b)
}

What happens under the hood

When you call rand.Read, the Go runtime asks the operating system for random bytes. On Linux, this reads from /dev/urandom. On macOS, it uses getentropy. On Windows, it calls the cryptographic API. The OS mixes hardware noise and system events to produce the bytes.

The OS maintains an entropy pool. It collects timing data from interrupts. Disk I/O latency varies. Network packet arrival times vary. The kernel mixes these sources using a cryptographic hash function. The result is a stream of bytes that passes statistical tests for randomness and resists prediction.

On Linux, crypto/rand uses /dev/urandom. It does not use /dev/random. The name urandom suggests it might block, but modern kernels reseed the pool frequently enough that it never blocks under normal load. Go avoids /dev/random because that interface can block and cause service outages when entropy is low. Availability matters more than theoretical purity in production systems.

The call returns immediately on healthy systems. If the entropy pool is critically low, the call might block until the system gathers enough noise. This blocking is a safety feature. It prevents your code from returning predictable data when the system is starved for randomness.

The OS does the heavy lifting. Trust the kernel, but verify the error.

Encoding the output

Random bytes are binary data. You can't paste binary data into a URL or a database string column without encoding. Hex encoding maps each byte to two ASCII characters. It's safe for URLs and databases. Base64 is more compact but introduces padding characters and case sensitivity issues. For tokens, hex is usually the pragmatic choice. It's slightly longer, but it avoids decoding errors in web frameworks and log parsers.

A realistic token generator

Here's a production-ready function that generates a secure token. It wraps the randomness, checks the byte count, and returns a hex-encoded string.

package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
)

// GenerateToken creates a secure random token encoded as a hex string.
// It returns a 32-character string representing 16 random bytes.
func GenerateToken() (string, error) {
	// 16 bytes provides 128 bits of entropy.
	// This is the standard size for session identifiers and API keys.
	token := make([]byte, 16)

	// Fill the buffer with cryptographically secure random data.
	// rand.Read returns the number of bytes read or an error.
	n, err := rand.Read(token)
	if err != nil {
		// Propagate the error. If the OS can't provide randomness,
		// the caller needs to know so it can handle the failure.
		return "", fmt.Errorf("failed to read random bytes: %w", err)
	}

	// n should equal len(token). If it's less, the buffer wasn't fully filled.
	// This is extremely rare but worth checking for correctness.
	if n != len(token) {
		return "", fmt.Errorf("short read: got %d bytes, want %d", n, len(token))
	}

	// Encode the bytes to a hex string.
	// Hex encoding doubles the length, so 16 bytes become 32 characters.
	return hex.EncodeToString(token), nil
}

func main() {
	token, err := GenerateToken()
	if err != nil {
		panic(err)
	}
	fmt.Println(token)
}

Functions that perform I/O or blocking operations should accept a context.Context as the first argument. rand.Read doesn't take a context, but your wrapper should if it's part of a larger flow. However, rand.Read is usually fast enough that context cancellation isn't the primary concern. The real convention here is error propagation. Never swallow the error from rand.Read. If randomness fails, the system is compromised. The if err != nil boilerplate is verbose by design. It makes the unhappy path visible.

Wrap the randomness in a function. Return the error. Encode for the transport.

Pitfalls and compiler errors

The compiler rejects code that ignores return values. If you write rand.Read(b) without capturing the error, you get err declared and not used. You must handle the error, even if you just panic in a toy program. In production, log and fail.

Forget to import crypto/rand and the compiler rejects the program with undefined: rand. Confuse crypto/rand with math/rand and you might not get a compiler error, but your security is broken. The compiler cannot detect logical flaws in randomness. You have to choose the right package.

A common runtime mistake is reusing a buffer without clearing it. rand.Read overwrites the slice contents, but if you append to a slice or reuse a buffer in a loop, you might leak previous values. Always allocate a fresh slice for each token, or use rand.Read to fill a fixed-size buffer and copy the result.

Another pitfall is using math/rand for secrets because it's faster. math/rand is fast. crypto/rand is slower. The difference matters when you're generating millions of numbers for a simulation. It doesn't matter when you're generating a session token once per request. Security is a property of the use case, not the code. Pick the tool that matches the risk.

The compiler checks syntax. You check security. math/rand compiles fine and breaks your app.

When to use crypto/rand

Use crypto/rand when you generate session tokens, API keys, password reset codes, or any value where predictability allows an attacker to impersonate a user or access data.

Use math/rand/v2 when you need fast random numbers for simulations, games, load testing, or non-security-sensitive shuffling where performance matters and predictability is acceptable.

Use math/rand (legacy) only when maintaining old codebases that haven't migrated to the v2 API; new code should prefer math/rand/v2 for better performance and a cleaner interface.

Use a fixed seed with math/rand/v2.New when you need reproducible results for debugging or deterministic tests.

Security is a property of the use case, not the code. Pick the tool that matches the risk.

Where to go next