CWE-331: Insufficient Entropy - C#
Overview
Insufficient entropy is a question about the number of unpredictable bits in a value, not about which type produced it. In C# it shows up in three shapes. A seeded generator - new Random((int)DateTime.Now.Ticks) - caps everything that follows at a 32-bit seed, and usually far less than that once the attacker narrows the time window. An output that is simply too small - a 4-digit PIN, GetBytes(4), an 8-character invite code - falls to brute force whatever filled it. And a format with a fixed ceiling, notably Guid.NewGuid()'s 122 bits, cannot be raised by care taken elsewhere.
Primary Defence: Size the value against what it protects - at least 16 bytes for a token and 32 for key material - then fill it with System.Security.Cryptography.RandomNumberGenerator: the static RandomNumberGenerator.GetBytes(int) and RandomNumberGenerator.GetInt32(int) on .NET 6+, or RandomNumberGenerator.Create() on earlier versions. Do not reach for RNGCryptoServiceProvider in new code: it has been obsolete since .NET 6 and raises compile warning SYSLIB0023.
The related finding that the generator is not cryptographic - System.Random, Random.Shared - is CWE-338. The two are usually reported on the same line and both are covered below.
Common Vulnerable Patterns
Using Random with time-based seed for tokens
using System;
// VULNERABLE - Predictable token generation
public class InsecureTokenGenerator
{
// VULNERABLE - Time-based seed
public static string GenerateSessionToken()
{
var random = new Random((int)DateTime.Now.Ticks);
return random.Next().ToString("X");
}
}
Why this is vulnerable: The output space is the seed space.
Random(int) takes a 32-bit seed, so this token is one of at most 2^32
sequences no matter what the algorithm behind it does - and in practice far
fewer, because (int)DateTime.Now.Ticks is not uniform over that range. Ticks
advance in 100-nanosecond units, so an attacker who knows the request happened
within a one-second window has 10 million candidates, and one who knows the
minute has 600 million: both are enumerable offline in the time it takes to
read this. random.Next() narrows it again, returning a non-negative int -
31 bits at best.
Using Random for encryption keys
using System;
// VULNERABLE - Using Random for encryption key
public static byte[] GenerateKey()
{
var random = new Random();
byte[] key = new byte[32];
random.NextBytes(key); // Predictable!
return key;
}
Why this is vulnerable: System.Random is not a CSPRNG: the 32 bytes it
writes are 32 bytes of a sequence that follows from its internal state, and
enough observed output reveals that state. A buffer sized correctly for AES-256
is not the same thing as 256 bits an attacker cannot predict.
Using Random for API keys
using System;
using System.Text;
// VULNERABLE - API key generation
public static string GenerateApiKey()
{
var random = new Random();
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 32; i++)
{
sb.Append(chars[random.Next(chars.Length)]);
}
return sb.ToString();
}
Why this is vulnerable: Every character is drawn from the same System.Random
instance, so the whole 32-character key follows from that instance's state - and
because System.Random is not a CSPRNG, enough observed output gives the state
away. The small alphabet caps the bits per character on top of that.
Using Guid.NewGuid() as key material
using System;
using System.Security.Cryptography;
using System.Text;
// VULNERABLE - 122 bits, presented to the key API as if it were 288
public static byte[] DeriveSigningKey()
{
string material = Guid.NewGuid().ToString(); // 36 chars of hex and dashes
return Encoding.UTF8.GetBytes(material); // 36 bytes; 122 bits of entropy
}
Why this is vulnerable: Not because the generator is weak. Microsoft's own
documentation is specific: on Windows Guid.NewGuid() wraps CoCreateGuid and
"the generated GUID contains 122 bits of strong entropy", and on non-Windows
platforms "starting with .NET 6, this function calls the OS's underlying
cryptographically secure pseudo-random number generator". Measured over 200,000
GUIDs on .NET 10, exactly 6 of the 128 bits never vary - the version and variant
markers - and the other 122 do.
The problem is the ceiling and how it is disguised. Microsoft's guidance says
plainly that applications should not use NewGuid for cryptographic
purposes, for two reasons: a v4 UUID has a partially predictable bit pattern, so
it is not a proper pseudo-random function; and 122 bits sits below the 128-bit
input minimum that many cryptographic routines enforce as policy. The example
above makes it worse by passing the string form: 36 bytes go into the key API,
which reads as 288 bits, and 122 bits of entropy come out. Anything sizing a key
by material.Length will conclude this is ample.
Using a GUID as a session identifier or a lookup handle is fine and common. Using one where a key, a signing secret or a nonce is wanted is the finding.
Insufficient entropy in PIN generation
using System;
// VULNERABLE - Insufficient entropy (only 4 digits)
public static int GeneratePin()
{
var random = new Random();
return random.Next(10000); // 0000-9999
}
Why this is vulnerable:
- 4 digits is only 10,000 possibilities.
System.Randommakes the sequence guessable.
Random.Shared is newer, not stronger
// VULNERABLE - Random.Shared is thread-safe and better seeded, still not a CSPRNG
public static string GenerateInviteCode()
{
return Random.Shared.Next(100000, 999999).ToString();
}
Why this is vulnerable: .NET 6 added Random.Shared and improved how
new Random() seeds itself, which removed the old failure where instances
created in the same tick produced identical sequences. That fix is about
collisions between instances, not about predictability: the algorithm is still
a non-cryptographic PRNG whose future output follows from its state. Being the
newest API in the class makes it easy to assume the security concern was
addressed too. For anything secret, RandomNumberGenerator is the separate
type to reach for.
Secure Patterns
Using RandomNumberGenerator (.NET 6+)
using System;
using System.Security.Cryptography;
using System.Text;
public class SecureTokenGenerator
{
// Generate cryptographically secure token (hex-encoded)
public static string GenerateSecureToken(int bytes = 16)
{
byte[] token = RandomNumberGenerator.GetBytes(bytes);
return Convert.ToHexString(token).ToLower();
}
// Generate base64-encoded token
public static string GenerateBase64Token(int bytes = 32)
{
byte[] token = RandomNumberGenerator.GetBytes(bytes);
return Convert.ToBase64String(token)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('='); // URL-safe base64
}
// Generate session ID (256 bits)
public static string GenerateSessionId()
{
return GenerateSecureToken(32); // 64 hex chars
}
// Generate CSRF token (256 bits)
public static string GenerateCsrfToken()
{
return GenerateBase64Token(32);
}
// Generate API key (384 bits)
public static string GenerateApiKey()
{
return GenerateBase64Token(48);
}
// Generate password reset token (256 bits)
public static string GeneratePasswordResetToken()
{
return GenerateSecureToken(32);
}
// One-time code delivered out of band. NOT a token: 6 digits is
// 6 * log2(10) = 19.9 bits, only 100x the 4-digit PIN marked VULNERABLE
// above. What makes it usable is the attempt limit, the short expiry and
// single-use enforcement at the call site, not the generator.
public static string GenerateOtp(int length = 6)
{
StringBuilder pin = new StringBuilder();
for (int i = 0; i < length; i++)
{
// GetInt32 rejects and retries to keep every digit equally likely.
// Taking a byte % 10 would bias the result: 256 is not a multiple
// of 10, so 0-5 would occur slightly more often than 6-9.
pin.Append(RandomNumberGenerator.GetInt32(10));
}
return pin.ToString();
}
// Generate cryptographic key for AES-256
public static byte[] GenerateEncryptionKey(int bits = 256)
{
return RandomNumberGenerator.GetBytes(bits / 8);
}
// Generate IV for AES encryption
public static byte[] GenerateIV()
{
return RandomNumberGenerator.GetBytes(16); // 128 bits
}
// Generate random integer in range [0, max)
public static int GetSecureRandomInt(int max)
{
return RandomNumberGenerator.GetInt32(max);
}
}
Why this works:
- The byte counts are chosen against what each value protects, not against how the string looks. 16 bytes is the 128-bit floor for a session token; 32 bytes is 256 bits for anything long-lived. Those render as 32 hex characters and 43 base64url characters respectively, so a column sized by character count is the usual way the value gets truncated back below its intended strength.
RandomNumberGeneratordraws from the OS CSPRNG, is thread-safe, and has no seed for a caller to narrow.GetInt32maps into a range by rejection sampling, so the small alphabet does not reintroduce the bias that a modulo reduction would.
Legacy .NET (Framework/Core < 6) with RNGCryptoServiceProvider
Use only on .NET Framework or .NET Core below 6.
RNGCryptoServiceProvideris obsolete from .NET 6 onward and raises SYSLIB0023 at compile time. On .NET 6+ use the staticRandomNumberGeneratormethods shown above instead - this pattern is here for codebases that cannot yet move, not as an alternative to them.
using System;
using System.Security.Cryptography;
public class LegacySecureTokenGenerator
{
private static readonly RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
public static string GenerateSecureToken(int bytes = 16)
{
byte[] token = new byte[bytes];
rng.GetBytes(token);
return BitConverter.ToString(token).Replace("-", "").ToLower();
}
public static byte[] GenerateRandomBytes(int length)
{
byte[] bytes = new byte[length];
rng.GetBytes(bytes);
return bytes;
}
}
Why this works:
- Legacy CSPRNG that still uses OS entropy sources.
- Safe on older frameworks, where the static
RandomNumberGenerator.GetBytesandGetInt32helpers do not exist.RandomNumberGenerator.Create()is available there and is the better call where you can make it.
Complete encryption example with secure randomness
using System;
using System.Security.Cryptography;
using System.Text;
public class SecureEncryption
{
private const int TagSizeBytes = 16; // 128-bit authentication tag
// Generate AES-256 key using secure random
public static byte[] GenerateKey()
{
return RandomNumberGenerator.GetBytes(32); // 256 bits
}
// Encrypt with AES-GCM (authenticated encryption)
// Returns: (nonce, tag, ciphertext)
public static (byte[] nonce, byte[] tag, byte[] ciphertext) Encrypt(
byte[] plaintext,
byte[] key)
{
// Generate secure nonce (96 bits for GCM)
byte[] nonce = RandomNumberGenerator.GetBytes(12);
// Create tag buffer
byte[] tag = new byte[TagSizeBytes];
// Create cipher. Pass the tag size explicitly - the single-argument
// AesGcm(byte[]) constructor is obsolete from .NET 8 (SYSLIB0053).
using var aes = new AesGcm(key, TagSizeBytes);
// Encrypt
byte[] ciphertext = new byte[plaintext.Length];
aes.Encrypt(nonce, plaintext, ciphertext, tag);
return (nonce, tag, ciphertext);
}
// Decrypt AES-GCM ciphertext
public static byte[] Decrypt(
byte[] nonce,
byte[] tag,
byte[] ciphertext,
byte[] key)
{
// The constant, not tag.Length: sizing the cipher from the caller's
// tag lets a shorter tag - which GCM accepts, being a prefix of the
// full one - be presented and verified at reduced strength.
using var aes = new AesGcm(key, TagSizeBytes);
byte[] plaintext = new byte[ciphertext.Length];
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
}
// Usage example
var key = SecureEncryption.GenerateKey();
var plaintext = Encoding.UTF8.GetBytes("sensitive data");
var (nonce, tag, ciphertext) = SecureEncryption.Encrypt(plaintext, key);
var decrypted = SecureEncryption.Decrypt(nonce, tag, ciphertext, key);
Console.WriteLine(Encoding.UTF8.GetString(decrypted)); // "sensitive data"
Why this works:
- Keys and nonces come from a CSPRNG.
- GCM provides confidentiality and integrity.
Encryptdraws a fresh 96-bit nonce on every call instead of holding one, which is what keeps the same key from encrypting twice under the same nonce.
Framework-Specific Guidance
ASP.NET Core - Session Management
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Cryptography;
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDistributedMemoryCache();
services.AddSession(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.IdleTimeout = TimeSpan.FromMinutes(30);
// ASP.NET Core uses RandomNumberGenerator for session IDs automatically
});
services.AddAntiforgery(options =>
{
// Anti-forgery tokens use RandomNumberGenerator internally
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
}
}
Why this works:
- ASP.NET Core uses CSPRNG for session IDs and antiforgery tokens.
- The cookie options set here -
HttpOnly,Secure,SameSite=Strict, a 30-minute idle timeout - protect the identifier in transit and bound how long it stays useful.
ASP.NET Core - Custom Token Service
using Microsoft.AspNetCore.DataProtection;
using System.Security.Cryptography;
public interface ITokenService
{
string GenerateVerificationToken();
string GenerateApiKey();
}
public class TokenService : ITokenService
{
private readonly IDataProtectionProvider _dataProtection;
public TokenService(IDataProtectionProvider dataProtection)
{
_dataProtection = dataProtection;
}
public string GenerateVerificationToken()
{
// 32 bytes = 256 bits, rendered as 43 URL-safe characters. Verification
// tokens travel in email links, so standard Base64's '+' and '/' would
// be percent-encoded and eventually "cleaned up" by stripping them.
return UrlSafe(RandomNumberGenerator.GetBytes(32));
}
public string GenerateApiKey()
{
// 48 bytes = 384 bits, rendered as 64 URL-safe characters
string apiKey = UrlSafe(RandomNumberGenerator.GetBytes(48));
// Optionally encrypt with Data Protection API
var protector = _dataProtection.CreateProtector("ApiKeys");
return protector.Protect(apiKey);
}
// On .NET 9+ this is Base64Url.EncodeToString(bytes) from
// System.Buffers.Text; written out here so it works from .NET 6.
private static string UrlSafe(byte[] bytes) =>
Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
Why this works:
- Tokens come from
RandomNumberGenerator, notRandom, and are sized in bytes rather than characters: 32 bytes is 256 bits and 48 is 384, which render as 43 and 64 characters. Reading the character count is how a 32-byte token becomes a 32-character one. - URL-safe encoding preserves every bit. Standard Base64's
+and/survive a link only until something percent-encodes or strips them, and the usual repair is to shorten the token, which does not preserve the bits. - Data Protection can encrypt stored API keys.
Entity Framework Core - API Key Entity
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
public class ApiKey
{
[Key]
public int Id { get; set; }
public string UserId { get; set; }
// Store hashed key, not plaintext!
public string KeyHash { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public static (string plainKey, string hashedKey) GenerateApiKey()
{
// Generate secure random key
byte[] keyBytes = RandomNumberGenerator.GetBytes(48);
string plainKey = Convert.ToBase64String(keyBytes);
// Hash for storage. SHA-256 is the right choice here, NOT bcrypt or
// Argon2: those exist to make each guess expensive because a password
// carries perhaps 30 bits, and this key carries 384 from the CSPRNG.
// There is nothing to slow an attacker down for - only every
// authenticated request, since the key is verified on each one.
string hashedKey = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(plainKey)));
return (plainKey, hashedKey);
}
}
Why this works:
- 48 bytes is 384 bits, so the key is far past any brute-force threshold and the work factor of the storage hash is irrelevant to its strength. Entropy in the value is what lets the storage hash be fast.
- Only the hash is stored, so a database leak yields nothing usable.
- Compare the stored hash with a fixed-time comparison (
CryptographicOperations.FixedTimeEquals) rather than==, since the comparison runs on attacker-supplied input.
JWT Security
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
public class JwtTokenProvider
{
// SECURE - Generate signing key with RandomNumberGenerator
private static readonly byte[] _signingKey = RandomNumberGenerator.GetBytes(64);
private static readonly SymmetricSecurityKey _securityKey = new SymmetricSecurityKey(_signingKey);
public static string GenerateJwtToken(string userId)
{
// Generate unique JTI (JWT ID)
string jti = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(JwtRegisteredClaimNames.Jti, jti)
}),
Expires = DateTime.UtcNow.AddHours(1),
SigningCredentials = new SigningCredentials(
_securityKey,
SecurityAlgorithms.HmacSha256Signature)
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
Why this works:
- The signing key is 64 bytes and the JTI 16, both from
RandomNumberGeneratorrather thanRandom, so neither follows from a seed a caller could narrow. - Short expirations limit token exposure.
Considerations
Not every random value is a secret. Jitter on a retry delay, a sample row
for a report, a shuffled carousel - none of these gain an attacker anything if
guessed, and Random is the right class for them. The question is not "is this
random" but "does guessing it get someone something". Session IDs, reset
tokens, API keys, CSRF tokens, OTPs, salts, IVs and key material all fail that
test. If the value is not one of those, record the finding as a false positive
with the reason.
Length is a separate decision from class. GetBytes(4) is still only 32
bits. Use at least 16 bytes for tokens and 32 for key material. Prefer
Base64Url encoding for values that travel in a URL - standard Base64 emits
+ and /, which get percent-encoded and then, sooner or later, "cleaned up"
by stripping characters.
Mapping bytes to a range needs care. Taking a random byte modulo 10 does
not give ten equally likely digits, because 256 is not a multiple of 10 - the
low digits come up slightly more often. RandomNumberGenerator.GetInt32(min,
max) rejects and retries to avoid that. The bias is small enough to survive
review and large enough to matter for a short PIN or a small alphabet.
Guid.NewGuid() is CSPRNG-backed but fixed at 122 bits. That is reasonable
for a session identifier and short of what you want for key material. The
constraint is the format, so no care elsewhere raises it.
Watch instance lifetime, not just the type. A Random held as a field on a
service registered as a singleton is seeded once at startup, so every
deployment of that build produces the same sequence. The static
RandomNumberGenerator helpers avoid the question entirely - they need no
instance and no disposal, unlike the obsolete RNGCryptoServiceProvider.
Common Pitfalls
- Truncating output after generation instead of requesting less: Calling
RandomNumberGenerator.GetBytes()correctly, then shortening the result for "compact" storage withConvert.ToBase64String(bytes).Substring(0, 8)- truncating after the fact discards entropy the CSPRNG already produced. Request the byte count that matches the target length up front, and check the resulting bit length against the minimum for the token's purpose. - Reusing a nonce across
AesGcmcalls: Generating a nonce withRandomNumberGenerator.GetBytes(12)correctly once, then caching it as a static field and reusing it across multipleAesGcmencryptions with the same key - the fix addressed the source of the nonce but not the reuse, and AES-GCM nonce reuse under the same key is catastrophic regardless of how the nonce was generated.
Additional Resources
- ASP.NET Core Data Protection
- CWE-331: Insufficient Entropy
- OWASP Cryptographic Storage Cheat Sheet
- Guid.NewGuid Method - the source for the 122-bit figure, the CSPRNG guarantee on .NET 6+, and Microsoft's own advice not to use it for cryptographic purposes
- RandomNumberGenerator Class - the static
GetBytes(int)andGetInt32(int)used by the primary defence - SYSLIB0053: AesGcm without a tag size is obsolete - the source for passing the tag length to the
AesGcmconstructor - RNGCryptoServiceProvider Class (Legacy)
- SYSLIB0023: RNGCryptoServiceProvider is obsolete - the source for it being obsolete from .NET 6 and the recommended replacement