CWE-316: Cleartext Storage of Sensitive Information in Memory - C
Overview
Storing sensitive data (passwords, cryptographic keys, tokens) in memory as cleartext in C# exposes it to memory dumps, debuggers, and memory disclosure vulnerabilities. Regular strings are immutable and persist in memory until garbage collected. Prefer clearable arrays or spans where APIs allow them, clear arrays explicitly with Array.Clear(), and use unmanaged memory or SafeHandle only when the lifetime and cleanup requirements justify the complexity.
Primary Defence: Use char[] or byte[] for passwords and keys with explicit Array.Clear() in finally blocks where the input path permits it, use IDisposable/using for deterministic cleanup of sensitive buffers, and treat SecureString as legacy interop rather than a recommended general-purpose solution for new cross-platform .NET code.
Common Vulnerable Patterns
Storing password as String
using System;
// VULNERABLE - String is immutable, persists in memory
public class InsecureAuth
{
private string _password;
public bool Authenticate(string username, string password)
{
// Password stored as immutable string
// Cannot be cleared from memory
_password = password;
bool result = VerifyPassword(username, password);
// Setting to null doesn't clear original string
_password = null;
return result;
}
}
Storing API keys as string fields
// VULNERABLE - API keys persist in heap
public class APIClient
{
private string _apiKey;
private string _apiSecret;
public APIClient(string key, string secret)
{
// Immutable strings - visible in memory dumps
_apiKey = key;
_apiSecret = secret;
}
public async Task<string> MakeRequestAsync(string endpoint)
{
using var client = new HttpClient();
// API key exposed in memory
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _apiKey);
var response = await client.GetAsync(endpoint);
return await response.Content.ReadAsStringAsync();
}
}
Logging sensitive data
using Microsoft.Extensions.Logging;
// VULNERABLE - Password logged to files
public class LoginService
{
private readonly ILogger<LoginService> _logger;
public void Login(string username, string password)
{
_logger.LogDebug($"Login attempt: {username} with password {password}");
// Password now in log files and log string objects
bool success = Authenticate(username, password);
_logger.LogInformation($"Login result: {success}");
}
}
Not disposing SecureString
using System.Security;
// VULNERABLE - SecureString never disposed
public class PasswordForm
{
public bool Submit(SecureString password)
{
// Use password but never dispose
bool result = Authenticate(password);
// SecureString remains in memory - not cleared
return result;
}
}
Converting SecureString to String
using System;
using System.Runtime.InteropServices;
using System.Security;
// VULNERABLE - Defeats purpose of SecureString
public class PasswordHandler
{
public void ProcessPassword(SecureString securePassword)
{
// Converting to String creates cleartext copy
IntPtr ptr = Marshal.SecureStringToBSTR(securePassword);
try
{
string password = Marshal.PtrToStringBSTR(ptr);
// Now password exists in both SecureString and String
ProcessCredential(password);
// Even if we clear SecureString, string remains
}
finally
{
Marshal.ZeroFreeBSTR(ptr);
}
}
}
Secure Patterns
Legacy SecureString interop
Platform Note Warning: Microsoft recommends that
SecureStringnot be used for new .NET development. It can reduce exposure for some legacy interop paths, but .NET often has to convert it to plaintext to use it, and protection is not available consistently across platforms.
using System;
using System.Runtime.InteropServices;
using System.Security;
public class SecureAuth
{
public bool Authenticate(string username, SecureString password)
{
// Legacy interop example. Prefer clearable arrays for new code.
// Must dispose SecureString and clear unmanaged plaintext copies.
IntPtr ptr = IntPtr.Zero;
try
{
// Convert to unmanaged memory for use
ptr = Marshal.SecureStringToBSTR(password);
// Use pointer directly, avoid converting to string
bool result = VerifyPasswordPtr(username, ptr);
return result;
}
finally
{
// Always clear unmanaged memory
if (ptr != IntPtr.Zero)
{
Marshal.ZeroFreeBSTR(ptr);
}
}
}
private bool VerifyPasswordPtr(string username, IntPtr passwordPtr)
{
// Work with pointer to avoid creating string
// Compare with stored hash
return true; // Implement actual verification
}
}
Why this works:
SecureStringis legacy mitigation, not a general fix: It tries to reduce plaintext exposure, but modern .NET guidance discourages new use- Unmanaged memory allows secure overwriting: Unlike managed heap, can be zeroed before release
Marshal.ZeroFreeBSTRclears memory securely: Zeros unmanaged memory before releasing itfinallyensures cleanup: Memory cleared even if exceptions occur- Platform and API limitations: Protection is platform-dependent, and many APIs require plaintext conversion before use
- Microsoft guidance: Not recommended for new .NET code
Using char[] with explicit clearing
Cross-Platform Pattern: This pattern works consistently on all platforms when the surrounding APIs can accept clearable arrays.
using System;
public class SecurePasswordHandler
{
public bool AuthenticateWithCharArray(string username, char[] password)
{
try
{
// Use char array for password
byte[] passwordBytes = System.Text.Encoding.UTF8.GetBytes(password);
try
{
// Hash and verify
byte[] hash = HashPassword(passwordBytes);
return CompareHashes(hash, GetStoredHash(username));
}
finally
{
// Clear byte array
Array.Clear(passwordBytes, 0, passwordBytes.Length);
}
}
finally
{
// Always clear char array
Array.Clear(password, 0, password.Length);
}
}
private byte[] HashPassword(byte[] passwordBytes)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
return sha256.ComputeHash(passwordBytes);
}
}
Why this works:
char[]is mutable and clearable:Array.Clear()zeros memory, unlike immutable strings that persist until GC- Nested
try-finallyensures complete cleanup: Bothchar[]and intermediatebyte[]are zeroed - Minimizes cleartext window: Clearing
byte[]immediately after use reduces exposure time - Cross-platform consistency: Works identically on Windows, Linux, macOS
- Practical new-code default: Preferred over
SecureStringfor new cross-platform code when the surrounding API can consume arrays or spans
Optional In-Memory Encryption Wrapper
Advanced defense-in-depth: This pattern encrypts secrets while they are idle inside application objects. It does not protect against full process memory compromise, because the session key and temporary plaintext also exist in the same process memory. Use it only when the added complexity is justified and after simpler lifetime reduction, clearing, dump controls, and key-management measures are in place.
How it works (4 layers):
- SecureKeyManager - AES-256-GCM encryption with random nonces
- SecretEncryption - Session key management & auto-cleanup
- ProtectedSecret - High-level API with automatic memory clearing
- Your application code using ProtectedSecret
Key management (automatic):
SecretEncryption.Instanceis a singleton - one instance per application- On first use, generates 32-byte random encryption key automatically
- Key lives in memory for entire app lifetime
- Cleared on app shutdown (handles
ProcessExit,DomainUnload,Ctrl+C) - You never handle the key directly - just call
ProtectInMemory()/UnprotectFromMemory()
Data flow:
- Password arrives as
char[],ProtectedSecretencrypts it, and the wrapper stores encryptedbyte[]in RAM - When needed,
UseSecret()temporarily decrypts tochar[], scopes access to a callback, then clears the temporary array - Result: The application controls plaintext lifetime more explicitly, but process dumps that include the session key may still be enough to recover the secret
using System;
using System.Security.Cryptography;
using System.Text;
// LAYER 1: Low-level encryption utility
public class SecureKeyManager(byte[] key) : IDisposable
{
// Use explicit standard sizes for AES-GCM
private const int NonceSize = 12; // 96 bits - standard for GCM
private const int TagSize = 16; // 128 bits - standard authentication tag
// Associated data for binding ciphertext to context
private static readonly byte[] DefaultAad = Encoding.UTF8.GetBytes("Dipsy.MemoryProtection:v1");
private byte[]? _keyBytes = InitializeKey(key);
private bool _disposed = false;
private static byte[] InitializeKey(byte[] key)
{
// Store key in byte array (must be 32 bytes for AES-256)
if (key.Length != 32)
{
throw new ArgumentException("Key must be 32 bytes for AES-256-GCM");
}
var keyBytes = new byte[key.Length];
Array.Copy(key, keyBytes, key.Length);
return keyBytes;
}
public byte[] Encrypt(byte[] plaintext)
{
if (_disposed)
{
throw new ObjectDisposedException("SecureKeyManager");
}
// Use AesGcm class for authenticated encryption (.NET Core 3.0+)
using var aesGcm = new AesGcm(_keyBytes!, TagSize);
// Generate random nonce (12 bytes - standard for GCM)
var nonce = new byte[NonceSize];
RandomNumberGenerator.Fill(nonce);
// Allocate space for ciphertext and auth tag
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];
try
{
// Encrypt with authentication and associated data (AAD)
// AAD binds ciphertext to context, prevents mix-and-match attacks
aesGcm.Encrypt(nonce, plaintext, ciphertext, tag, DefaultAad);
// Return: nonce + tag + ciphertext
var result = new byte[nonce.Length + tag.Length + ciphertext.Length];
Buffer.BlockCopy(nonce, 0, result, 0, nonce.Length);
Buffer.BlockCopy(tag, 0, result, nonce.Length, tag.Length);
Buffer.BlockCopy(ciphertext, 0, result, nonce.Length + tag.Length, ciphertext.Length);
return result;
}
finally
{
// Clear intermediate buffers for defense in depth
Array.Clear(nonce, 0, nonce.Length);
Array.Clear(tag, 0, tag.Length);
Array.Clear(ciphertext, 0, ciphertext.Length);
}
}
public byte[] Decrypt(byte[] encryptedData)
{
if (_disposed)
{
throw new ObjectDisposedException("SecureKeyManager");
}
// Validate minimum length before slicing
int minLength = NonceSize + TagSize;
if (encryptedData == null || encryptedData.Length < minLength)
{
throw new CryptographicException(
$"Encrypted data must be at least {minLength} bytes (nonce + tag). Received: {encryptedData?.Length ?? 0} bytes");
}
// Extract nonce, tag, and ciphertext
var nonce = new byte[NonceSize];
var tag = new byte[TagSize];
var ciphertext = new byte[encryptedData.Length - NonceSize - TagSize];
Buffer.BlockCopy(encryptedData, 0, nonce, 0, NonceSize);
Buffer.BlockCopy(encryptedData, NonceSize, tag, 0, TagSize);
Buffer.BlockCopy(encryptedData, NonceSize + TagSize, ciphertext, 0, ciphertext.Length);
// Decrypt and verify authentication tag
using var aesGcm = new AesGcm(_keyBytes!, TagSize);
var plaintext = new byte[ciphertext.Length];
try
{
// Decrypt with AAD verification - ensures ciphertext hasn't been moved between contexts
aesGcm.Decrypt(nonce, ciphertext, tag, plaintext, DefaultAad);
return plaintext;
}
catch
{
// Clear plaintext buffer on decryption failure (bad tag, etc.)
Array.Clear(plaintext, 0, plaintext.Length);
throw;
}
finally
{
// Clear intermediate buffers for defense in depth
Array.Clear(nonce, 0, nonce.Length);
Array.Clear(tag, 0, tag.Length);
Array.Clear(ciphertext, 0, ciphertext.Length);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (_keyBytes != null)
{
// Clear key from memory
Array.Clear(_keyBytes, 0, _keyBytes.Length);
_keyBytes = null;
}
_disposed = true;
}
~SecureKeyManager()
{
Dispose(false);
}
}
using System.Security.Cryptography;
using System.Text;
/// <summary>
/// Singleton for encrypting/decrypting sensitive data in memory.
/// The session key is randomly generated on first use and cleared on application shutdown (best-effort).
///
/// LIMITATIONS:
/// - Cleanup hooks are best-effort and won't run on hard termination/crash
/// - Encoding conversions may create transient runtime buffers that can't be reliably wiped in managed environments
/// - Key material is stored in managed memory (subject to GC movement)
/// </summary>
public sealed class SecretEncryption
{
private static readonly Lazy<SecretEncryption> _instance = new(() => new SecretEncryption());
public static SecretEncryption Instance => _instance.Value;
private readonly SecureKeyManager _keyManager;
private volatile bool _disposed = false;
private SecretEncryption()
{
// Generate random session key and create single SecureKeyManager instance
byte[] sessionKey = new byte[32];
RandomNumberGenerator.Fill(sessionKey);
try
{
_keyManager = new SecureKeyManager(sessionKey);
}
finally
{
// Clear the temporary session key array
Array.Clear(sessionKey, 0, sessionKey.Length);
}
// Multiple cleanup hooks for different shutdown scenarios
try
{
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
AppDomain.CurrentDomain.DomainUnload += OnProcessExit;
// For console apps - handle Ctrl+C
Console.CancelKeyPress += OnCancelKeyPress;
}
catch
{
// Event registration failed - still continue
// Key will be cleared by finalizer if needed
}
}
private void OnProcessExit(object? sender, EventArgs e)
{
try
{
Cleanup();
}
catch
{
// Suppress exceptions during shutdown
// Don't prevent other cleanup handlers from running
}
}
private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
{
try
{
Cleanup();
}
catch
{
// Suppress exceptions
}
}
public byte[] ProtectInMemory(char[] secret)
{
if (_disposed)
{
throw new ObjectDisposedException("SecretEncryption");
}
try
{
// Convert char[] to bytes
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
try
{
// Encrypt using shared session key manager
// No lock needed - SecureKeyManager creates new AesGcm per call
return _keyManager.Encrypt(secretBytes);
}
finally
{
// Clear plaintext secret bytes
Array.Clear(secretBytes, 0, secretBytes.Length);
}
}
finally
{
// Clear original char array
Array.Clear(secret, 0, secret.Length);
}
}
public char[] UnprotectFromMemory(byte[] encryptedData)
{
if (_disposed)
{
throw new ObjectDisposedException("SecretEncryption");
}
// Decrypt using shared session key manager
// No lock needed - SecureKeyManager creates new AesGcm per call
byte[] secretBytes = _keyManager.Decrypt(encryptedData);
try
{
// Decode UTF-8 bytes directly to char[] without creating intermediate string
int charCount = Encoding.UTF8.GetCharCount(secretBytes);
char[] result = new char[charCount];
Encoding.UTF8.GetChars(secretBytes, 0, secretBytes.Length, result, 0);
return result;
}
finally
{
// Clear decrypted bytes
Array.Clear(secretBytes, 0, secretBytes.Length);
}
}
private void Cleanup()
{
if (_disposed) return;
_disposed = true;
_keyManager?.Dispose();
}
}
/// <summary>
/// Stores a secret encrypted in memory. The secret is only decrypted temporarily when accessed via UseSecret callbacks.
/// Use ProtectedSecret.Consume() to create an instance.
/// </summary>
public class ProtectedSecret : IDisposable
{
private byte[]? _encryptedData;
private bool _disposed = false;
/// <summary>
/// Private constructor - use Consume() factory method instead.
/// </summary>
private ProtectedSecret(byte[] encryptedData)
{
_encryptedData = encryptedData;
}
/// <summary>
/// Creates a new ProtectedSecret by consuming and encrypting the provided secret.
/// The input secret array is cleared (zeroed) for security after encryption.
/// </summary>
/// <param name="secret">Secret as char array. This array will be cleared (zeroed) after encryption.</param>
/// <returns>A new ProtectedSecret with the encrypted secret.</returns>
public static ProtectedSecret Consume(char[] secret)
{
byte[] encryptedData = SecretEncryption.Instance.ProtectInMemory(secret);
// Note: secret array is now cleared (all zeros) by ProtectInMemory
return new ProtectedSecret(encryptedData);
}
/// <summary>
/// Safely use the secret within a callback. The secret is automatically cleared after the callback completes.
/// Callers must not copy the secret (e.g., new string(secret) or secret.ToArray()) as copies won't be cleared.
/// </summary>
/// <param name="action">Callback that receives the secret as a ReadOnlySpan. Do not store or copy this span - work with it directly.</param>
/// <exception cref="ObjectDisposedException">Thrown if this ProtectedSecret has been disposed.</exception>
/// <exception cref="ArgumentNullException">Thrown if action is null.</exception>
public void UseSecret(Action<ReadOnlySpan<char>> action)
{
if (_disposed)
{
throw new ObjectDisposedException("ProtectedSecret");
}
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
char[]? tempSecret = null;
try
{
tempSecret = SecretEncryption.Instance.UnprotectFromMemory(_encryptedData!);
// Pass as ReadOnlySpan to prevent caller from modifying
action(tempSecret.AsSpan());
}
finally
{
// Plaintext is auto-cleared after callback; callers must not copy it
if (tempSecret != null)
{
Array.Clear(tempSecret, 0, tempSecret.Length);
}
}
}
/// <summary>
/// Safely use the secret within a callback that returns a result. The secret is automatically cleared after the callback completes.
/// Callers must not copy the secret (e.g., new string(secret) or secret.ToArray()) as copies won't be cleared.
/// </summary>
/// <typeparam name="TResult">The type of result returned by the callback.</typeparam>
/// <param name="func">Callback that receives the secret as a ReadOnlySpan and returns a result. Do not store or copy the span - work with it directly.</param>
/// <returns>The result from the callback.</returns>
/// <exception cref="ObjectDisposedException">Thrown if this ProtectedSecret has been disposed.</exception>
/// <exception cref="ArgumentNullException">Thrown if func is null.</exception>
public TResult UseSecret<TResult>(Func<ReadOnlySpan<char>, TResult> func)
{
if (_disposed)
{
throw new ObjectDisposedException("ProtectedSecret");
}
if (func == null)
{
throw new ArgumentNullException(nameof(func));
}
char[]? tempSecret = null;
try
{
tempSecret = SecretEncryption.Instance.UnprotectFromMemory(_encryptedData!);
// Pass as ReadOnlySpan to prevent caller from modifying
return func(tempSecret.AsSpan());
}
finally
{
// Plaintext is auto-cleared after callback; callers must not copy it
if (tempSecret != null)
{
Array.Clear(tempSecret, 0, tempSecret.Length);
}
}
}
/// <summary>
/// Disposes this ProtectedSecret, clearing the encrypted data from memory.
/// </summary>
public void Dispose()
{
if (_disposed) return;
// Clear encrypted data
if (_encryptedData != null)
{
Array.Clear(_encryptedData, 0, _encryptedData.Length);
_encryptedData = null;
}
_disposed = true;
}
}
// Your application code (usage example)
// Simulate password coming from another source, ready for storage
char[] password = "<redacted-password>".ToCharArray();
using var ProtectedSecret = ProtectedSecret.Consume(password);
// password[] is now all zeros - it was consumed!
// Use password safely with automatic cleanup
ProtectedSecret.UseSecret(pwd =>
{
// Use password briefly for authentication
// pwd is a ReadOnlySpan<char> - should not be stored or copied
AuthenticateUser(pwd);
// Plaintext automatically cleared when callback completes!
});
Why this works:
- Idle-state encryption: AES-256-GCM with random nonces encrypts secrets while they are stored inside the wrapper, reducing accidental plaintext exposure in ordinary object graphs
- Four-layer architecture:
SecureKeyManager(AES-GCM encryption),SecretEncryption(singleton session key),ProtectedSecret(high-level API), application code (simple calls) - Session key lifecycle: Randomly generated on first use, lives only in app memory (never persisted), and is cleared on shutdown via best-effort cleanup hooks
- Scoped plaintext exposure:
ReadOnlySpan<char>callback inUseSecret()discourages copying; plaintext is cleared after callback completion if the callback does not create its own copies - AAD binding: Associated Authenticated Data prevents ciphertext mix-and-match attacks across different contexts
ASP.NET Core authentication with secure password handling
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Security;
using System.Threading.Tasks;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AuthController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
// Convert password to char array immediately
char[] passwordChars = request.Password.ToCharArray();
try
{
var user = await _userManager.FindByNameAsync(request.Username);
if (user == null)
{
return Unauthorized(new { error = "Invalid credentials" });
}
// ASP.NET Core Identity handles password hashing securely.
// This code still clears its local char[] copy; request strings may remain until GC.
var result = await _signInManager.CheckPasswordSignInAsync(
user,
request.Password,
lockoutOnFailure: false
);
if (result.Succeeded)
{
return Ok(new { success = true });
}
return Unauthorized(new { error = "Invalid credentials" });
}
finally
{
// Clear password char array
Array.Clear(passwordChars, 0, passwordChars.Length);
}
}
}
public class LoginRequest
{
public string Username { get; set; }
public string Password { get; set; }
}
Why this works:
- ASP.NET Core Identity uses secure hashing: PBKDF2 with HMAC-SHA256 (or bcrypt/Argon2) prevents cleartext storage
- Constant-time comparison prevents timing attacks: Password verification doesn't leak information through timing
char[]withfinallyminimizes cleartext exposure: Password cleared from memory even on exceptions- Generic error messages prevent enumeration: Same message for non-existent users and wrong passwords
- Built-in lockout protection: Setting
lockoutOnFailure: true(production) prevents brute-force attacks
Secure credential storage with SafeHandle
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
public class SecureCredentialHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private SecureCredentialHandle() : base(true) { }
public SecureCredentialHandle(IntPtr preexistingHandle, bool ownsHandle)
: base(ownsHandle)
{
SetHandle(preexistingHandle);
}
protected override bool ReleaseHandle()
{
// Zero memory before freeing
if (handle != IntPtr.Zero)
{
// Get size of allocated memory (store separately)
// Zero the memory
// Free the memory
return true;
}
return false;
}
}
public class SecureCredentialManager : IDisposable
{
private SecureCredentialHandle _credentialHandle;
public void StoreCredential(byte[] credential)
{
// Allocate unmanaged memory
IntPtr ptr = Marshal.AllocHGlobal(credential.Length);
try
{
// Copy credential to unmanaged memory
Marshal.Copy(credential, 0, ptr, credential.Length);
// Wrap in SafeHandle
_credentialHandle = new SecureCredentialHandle(ptr, true);
}
catch
{
// If SafeHandle creation fails, manually free
Marshal.FreeHGlobal(ptr);
throw;
}
finally
{
// Clear original credential
Array.Clear(credential, 0, credential.Length);
}
}
public void Dispose()
{
_credentialHandle?.Dispose();
}
}
Why this works:
SafeHandleensures automatic memory clearing: Zeros and frees unmanaged memory when disposed- Unmanaged memory avoids GC issues: Not subject to garbage collection copying/compaction
- Critical region prevents use-after-free: Memory can't be freed while another thread is using it
- Finalizer provides safety net:
ReleaseHandle()called even ifDispose()not explicitly called - Single controlled copy: Moving to unmanaged memory and clearing managed copy reduces exposure
- Native API compatibility: Unmanaged memory can be passed directly via pointers without string copies
JWT token handling with Data Protection API
using Microsoft.AspNetCore.DataProtection;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
public class SecureJWTHandler : IDisposable
{
private byte[] _secretKey;
private readonly IDataProtector _protector;
private bool _disposed = false;
public SecureJWTHandler(byte[] secretKey, IDataProtectionProvider provider)
{
// Store secret in byte array
_secretKey = new byte[secretKey.Length];
Array.Copy(secretKey, _secretKey, secretKey.Length);
// Use Data Protection API for additional encryption
_protector = provider.CreateProtector("JWTSecrets");
}
public string CreateToken(string userId)
{
if (_disposed)
{
throw new ObjectDisposedException("SecureJWTHandler");
}
var tokenHandler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(_secretKey);
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: new[] { new Claim(ClaimTypes.NameIdentifier, userId) },
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials
);
return tokenHandler.WriteToken(token);
}
public ClaimsPrincipal ValidateToken(string token)
{
if (_disposed)
{
throw new ObjectDisposedException("SecureJWTHandler");
}
var tokenHandler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(_secretKey);
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.Zero
};
return tokenHandler.ValidateToken(token, validationParameters, out _);
}
public void Dispose()
{
if (!_disposed)
{
if (_secretKey != null)
{
Array.Clear(_secretKey, 0, _secretKey.Length);
_secretKey = null;
}
_disposed = true;
}
}
}
// Usage
public class TokenService
{
private readonly IDataProtectionProvider _dataProtectionProvider;
public string GenerateToken(string userId, byte[] secretKey)
{
using var jwtHandler = new SecureJWTHandler(secretKey, _dataProtectionProvider);
try
{
return jwtHandler.CreateToken(userId);
}
finally
{
// Clear secret key
Array.Clear(secretKey, 0, secretKey.Length);
}
}
}
Why this works:
- Multiple security layers: Mutable
byte[]cleared withArray.Clear(), ASP.NET Core Data Protection API for encryption-at-rest, JWT with HMAC-SHA256 + expiration - Controlled memory:
SymmetricSecurityKeywraps byte array for JWT signing; key held only duringSecureJWTHandlerlifetime, cleared on disposal - Defense-in-depth: Data Protection API provides automatic key management/encryption/rotation; keys stored encrypted with machine-specific keys (DPAPI on Windows, keyring on Linux)
- Machine-specific protection: Even if in-memory key compromised, persisted protected version can't be easily decrypted on different machine
- Comprehensive validation:
ClockSkew = TimeSpan.Zeroremoves default tolerance; signature verification and claim extraction while key remains in clearable memory
Secure password hashing with BCrypt
using System;
using BCrypt.Net;
public class SecurePasswordService
{
public string HashPassword(char[] password)
{
// Convert to string for BCrypt (necessary)
string passwordString = new string(password);
try
{
// BCrypt automatically salts and hashes
return BCrypt.Net.BCrypt.HashPassword(passwordString);
}
finally
{
// Clear char array
Array.Clear(password, 0, password.Length);
}
}
public bool VerifyPassword(char[] password, string hash)
{
string passwordString = new string(password);
try
{
// Verify password against hash
return BCrypt.Net.BCrypt.Verify(passwordString, hash);
}
finally
{
// Clear char array
Array.Clear(password, 0, password.Length);
}
}
}
Why this works:
- Purpose-built algorithm: BCrypt designed for password storage with automatic salting, configurable work factor (cost), resistance to rainbow tables and GPU brute-force
- Temporary string trade-off: Converts
char[]to string for BCrypt API, then immediately clearschar[]infinallyto minimize cleartext exposure - Integrated salt management:
HashPassword()generates random salt and stores with hash in single string ($2a$10$...), eliminating separate salt management - Configurable work factor: Default 10-12 rounds (adjustable) makes hashing intentionally slow (~100-300ms), increasing brute-force time while acceptable for auth
- Constant-time verification:
Verify()prevents timing attacks; brief string existence acceptable for well-tested library vs manual PBKDF2/Argon2 implementation
NetworkCredential with SecureString
using System;
using System.Net;
using System.Security;
public class SecureHttpClient
{
public HttpWebRequest CreateAuthenticatedRequest(
string url,
string username,
SecureString password)
{
var request = (HttpWebRequest)WebRequest.Create(url);
// NetworkCredential accepts SecureString.
// Plaintext still exists at the authentication protocol boundary.
request.Credentials = new NetworkCredential(username, password);
return request;
}
}
// Usage
public void MakeRequest()
{
var password = new SecureString();
// Build SecureString character by character
foreach (char c in GetPasswordFromUser())
{
password.AppendChar(c);
}
// Make password read-only
password.MakeReadOnly();
try
{
var client = new SecureHttpClient();
var request = client.CreateAuthenticatedRequest(
"https://api.example.com",
"username",
password
);
// Use request
}
finally
{
// Dispose SecureString
password.Dispose();
}
}
Why this works:
- Native SecureString support:
NetworkCredential(string userName, SecureString password)can avoid creating an application-level password string, but the credential still becomes plaintext at the point required by the authentication protocol - Character-by-character building:
AppendChar()avoids creating one complete application-level immutable string before the credential object is built - Read-only state:
MakeReadOnly()prevents further mutation and signals that password entry is complete; it is not a full memory-protection guarantee - Automatic cleanup:
Dispose()infinallyclears theSecureStringobject even on exceptions; the underlying protocol may still need plaintext at the point of authentication - Session reuse: Useful for Windows-authenticated services with password entered once for multiple requests; Windows-specific due to
SecureStringencryption limitations
Remediation Steps
- Locate secrets held in memory: passwords, API keys, JWT signing keys, OAuth tokens, database credentials, and decrypted private keys.
- Trace how each value enters the application and identify immutable
stringcopies, logs, exception messages, request models, and long-lived fields. - Prefer short-lived
byte[],char[],Span<T>, or unmanaged buffers only where the surrounding APIs can consume them without converting back tostring. - Clear mutable buffers in
finallyblocks,Dispose()methods, orSafeHandlecleanup paths; document remaining unavoidable framework copies. - Add dump, swap, and debugger restrictions for high-risk processes, and keep long-lived secrets in vaults or platform key stores where practical.
- Re-scan and review crash-dump/logging paths to confirm secrets are not persisted after the fix.
Testing
- Normal input: authenticate, sign tokens, and call dependent services to confirm clearable-buffer refactors did not break expected flows.
- Boundary input: test failed authentication, exceptions, cancellation, and early returns to confirm cleanup paths still execute.
- Malicious input: inspect controlled crash dumps or debugger snapshots from test environments and verify local buffers are cleared and secrets are absent from logs.
Additional Resources
- AesGcm Class - .NET Core 3.0+ authenticated encryption
- SecureString Class
- Array.Clear Method
- SafeHandle Class
- Data Protection API
- CWE-316: Cleartext Storage of Sensitive Information in Memory
- OWASP Secure Coding Practices