Why secure randomness matters

Not all randomness is equal. A pseudo-random number generator used for simulations or games may be fine for non-security tasks, but security-sensitive values require a cryptographically secure random number generator (CSPRNG). A CSPRNG is designed so that even if an attacker observes many outputs, they still cannot predict future values.

Typical security uses include:

  • session IDs
  • password reset tokens
  • email verification codes
  • CSRF tokens
  • API secrets
  • salts and nonces
  • one-time challenge values

A weak generator can lead to account takeover, token forgery, or replay attacks. The risk is especially high when developers “roll their own” token generation using timestamps, counters, or non-cryptographic RNGs.

Choosing the right randomness source in Rust

Rust’s standard library does not provide a cryptographically secure RNG. For security-sensitive work, use the rand ecosystem, typically with OsRng, which draws entropy from the operating system.

The most common options are summarized below:

OptionSecurity levelTypical useNotes
thread_rng()Not guaranteed for security-critical design decisionsGeneral-purpose randomnessBacked by a CSPRNG in practice, but OsRng is clearer for security-sensitive code
OsRngHighTokens, secrets, noncesUses the OS entropy source directly
SmallRngLowFast simulations, testsNot suitable for secrets
Custom seeded RNGDepends on seed qualitySpecialized use casesEasy to misuse for security

For security-sensitive applications, prefer OsRng unless you have a specific reason not to.

Generating secure bytes

Many security values are best represented as raw bytes. For example, a 32-byte token gives you 256 bits of entropy, which is more than enough for most applications.

use rand::rngs::OsRng;
use rand::RngCore;

fn generate_secret_bytes(len: usize) -> Vec<u8> {
    let mut bytes = vec![0u8; len];
    OsRng.fill_bytes(&mut bytes);
    bytes
}

fn main() {
    let token = generate_secret_bytes(32);
    println!("Generated {} bytes of secret material", token.len());
}

This pattern is useful when you need:

  • binary keys for encryption
  • random salts
  • nonce values
  • opaque tokens that will later be encoded

A few practical notes:

  • Choose a length based on the security requirement, not convenience.
  • Do not truncate a secure value too aggressively.
  • Avoid reusing the same random value across different security contexts.

Encoding tokens for transport and storage

Raw bytes are not always convenient for URLs, JSON, or logs. In practice, you often encode random bytes as hex or Base64.

Hex encoding

Hex is simple, readable, and URL-safe, but it doubles the size of the data.

use rand::rngs::OsRng;
use rand::RngCore;

fn generate_hex_token(byte_len: usize) -> String {
    let mut bytes = vec![0u8; byte_len];
    OsRng.fill_bytes(&mut bytes);
    hex::encode(bytes)
}

fn main() {
    let token = generate_hex_token(32);
    println!("{token}");
}

URL-safe Base64

Base64 is more compact than hex and works well for tokens sent in links or headers. Use the URL-safe variant to avoid +, /, and padding issues in some contexts.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::rngs::OsRng;
use rand::RngCore;

fn generate_url_token(byte_len: usize) -> String {
    let mut bytes = vec![0u8; byte_len];
    OsRng.fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

fn main() {
    let token = generate_url_token(32);
    println!("{token}");
}

Which encoding should you choose?

EncodingProsConsBest for
HexSimple, easy to inspectLarger outputDebuggable secrets, hashes, identifiers
Base64 URL-safeCompact, URL-friendlyLess human-readableReset links, API tokens, headers
Raw bytesMost directNot text-friendlyInternal cryptographic operations

For externally visible tokens, URL-safe Base64 is often the best balance of size and usability.

Building a password reset token correctly

A common pattern is to generate a random token, store a hashed version in the database, and send the raw token to the user. This way, if the database is compromised, attackers do not immediately gain usable reset links.

A secure design looks like this:

  1. Generate 32 random bytes.
  2. Encode the bytes for transport.
  3. Store a hash of the token, not the token itself.
  4. Associate the token with a user and an expiration time.
  5. Verify the presented token by hashing and comparing the stored value.
  6. Invalidate the token after use.

Here is a simplified example:

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};

fn generate_reset_token() -> String {
    let mut bytes = [0u8; 32];
    OsRng.fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

fn hash_token(token: &str) -> [u8; 32] {
    let digest = Sha256::digest(token.as_bytes());
    let mut out = [0u8; 32];
    out.copy_from_slice(&digest);
    out
}

fn main() {
    let token = generate_reset_token();
    let stored_hash = hash_token(&token);

    println!("Send this token to the user: {token}");
    println!("Store this hash in the database: {:x?}", stored_hash);
}

This example uses SHA-256 as a storage hash, not as a password hash. That distinction matters:

  • For reset tokens and API secrets, a fast hash is acceptable because the token itself is already high entropy.
  • For user passwords, use a dedicated password hashing algorithm such as Argon2.

Avoiding common mistakes

Secure randomness is easy to use incorrectly. The following mistakes are especially common.

1. Using timestamps or counters as secrets

A token like user_id + timestamp is predictable. Even if it looks random enough in testing, attackers can often narrow the search space dramatically.

2. Using non-cryptographic RNGs

Generators intended for simulations or randomized algorithms are not suitable for secrets. They may be fast, but speed is not the goal for security.

3. Reusing tokens across purposes

A CSRF token should not also be used as a password reset token or API key. Different contexts have different threat models and lifetimes.

4. Generating too little entropy

Short tokens are easier to brute-force. For example, a 6-digit code is acceptable only when combined with strict rate limiting, short expiration, and abuse detection.

5. Logging secrets

Never log raw tokens, API keys, or session identifiers. If you need observability, log metadata such as token type, user ID, or issuance time.

When short verification codes are acceptable

Not every security value needs 256 bits of entropy. Email verification codes or MFA backup codes may be shorter, but they must be protected with compensating controls:

  • short expiration windows
  • rate limiting
  • lockout or backoff after repeated failures
  • one-time use
  • secure delivery channel

A numeric code can be generated securely from random bytes, but the security comes from the whole system, not the code alone.

use rand::rngs::OsRng;
use rand::RngCore;

fn generate_6_digit_code() -> u32 {
    let mut bytes = [0u8; 4];
    OsRng.fill_bytes(&mut bytes);
    let value = u32::from_le_bytes(bytes);
    100_000 + (value % 900_000)
}

fn main() {
    let code = generate_6_digit_code();
    println!("{code:06}");
}

This is better than deriving a code from time or a sequential counter. Still, a 6-digit code is only about one million possibilities, so it must be rate-limited and short-lived.

Comparing secure token formats

The right format depends on how the token will be used.

Use caseRecommended sizeEncodingNotes
Password reset link32 bytesURL-safe Base64Store only a hash server-side
Session ID32 bytesHex or Base64Rotate on login and privilege changes
CSRF token16–32 bytesBase64Bind to session and origin checks
API secret32–64 bytesBase64Show once; never log
Email verification code6–8 digitsDecimalMust be rate-limited and short-lived

A good rule is to prefer opaque, high-entropy tokens whenever possible. Human-friendly codes are a compromise, not a default.

Secure storage and lifecycle practices

Randomness alone does not make a system secure. You also need disciplined token lifecycle management.

Store only what you need

If a token is only needed for verification, store a hash of it. If you must store the token itself, encrypt it at rest and restrict access carefully.

Set expiration times

Every token should have an expiration policy. Short-lived tokens reduce the impact of leakage and replay.

Invalidate after use

One-time tokens should be removed or marked as consumed immediately after successful verification.

Scope tokens narrowly

A token should be valid only for the action it was created for. For example, a password reset token should not authorize profile changes or session creation.

Rotate secrets regularly

Long-lived API keys and signing secrets should be rotated with a controlled migration plan. Random generation makes rotation easy; process discipline makes it safe.

Testing secure randomness code

Testing randomness can be tricky because secure outputs are intentionally unpredictable. Focus on properties, not exact values.

Useful tests include:

  • length checks
  • encoding validity
  • uniqueness across many samples
  • round-trip decode/encode behavior
  • expiration and invalidation logic

For example, you can test that generated tokens are the expected length and decode successfully:

#[test]
fn token_is_url_safe_base64() {
    let token = generate_url_token(32);
    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(token.as_bytes())
        .expect("valid base64");
    assert_eq!(decoded.len(), 32);
}

Do not write tests that expect a specific random value. That defeats the purpose of randomness and often leads to brittle test design.

Practical checklist

Before shipping code that generates secrets or tokens, verify the following:

  • Use OsRng or another CSPRNG source.
  • Generate enough entropy for the threat model.
  • Encode tokens safely for their transport medium.
  • Store hashes instead of raw tokens when possible.
  • Set expiration and one-time-use rules.
  • Apply rate limiting to short verification codes.
  • Never log raw secrets.
  • Keep token scope narrow and explicit.

Conclusion

Secure randomness is one of the simplest security controls to implement correctly in Rust, but it is also easy to get subtly wrong. The safest approach is to use operating-system-backed randomness, generate sufficiently long opaque values, encode them appropriately, and manage their lifecycle carefully.

When you treat random values as first-class security objects, your Rust applications become much harder to attack through guessing, replay, or brute force.

Learn more with useful resources