Skip to content

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 SecureString not 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:

  • SecureString is 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.ZeroFreeBSTR clears memory securely: Zeros unmanaged memory before releasing it
  • finally ensures 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-finally ensures complete cleanup: Both char[] and intermediate byte[] 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 SecureString for 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):

  1. SecureKeyManager - AES-256-GCM encryption with random nonces
  2. SecretEncryption - Session key management & auto-cleanup
  3. ProtectedSecret - High-level API with automatic memory clearing
  4. Your application code using ProtectedSecret

Key management (automatic):

  • SecretEncryption.Instance is 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[], ProtectedSecret encrypts it, and the wrapper stores encrypted byte[] in RAM
  • When needed, UseSecret() temporarily decrypts to char[], 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
SecureKeyManager.cs
    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);
        }
    }
SecretEncryption.cs
    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();
        }
    }
ProtectedSecret.cs
    /// <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;
        }
    }
Example.cs
// 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 in UseSecret() 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[] with finally minimizes 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:

  • SafeHandle ensures 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 if Dispose() 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 with Array.Clear(), ASP.NET Core Data Protection API for encryption-at-rest, JWT with HMAC-SHA256 + expiration
  • Controlled memory: SymmetricSecurityKey wraps byte array for JWT signing; key held only during SecureJWTHandler lifetime, 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.Zero removes 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 clears char[] in finally to 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() in finally clears the SecureString object 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 SecureString encryption limitations

Remediation Steps

  1. Locate secrets held in memory: passwords, API keys, JWT signing keys, OAuth tokens, database credentials, and decrypted private keys.
  2. Trace how each value enters the application and identify immutable string copies, logs, exception messages, request models, and long-lived fields.
  3. Prefer short-lived byte[], char[], Span<T>, or unmanaged buffers only where the surrounding APIs can consume them without converting back to string.
  4. Clear mutable buffers in finally blocks, Dispose() methods, or SafeHandle cleanup paths; document remaining unavoidable framework copies.
  5. Add dump, swap, and debugger restrictions for high-risk processes, and keep long-lived secrets in vaults or platform key stores where practical.
  6. 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