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, 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, clearing them explicitly in finally blocks where the input path permits it - CryptographicOperations.ZeroMemory() for byte[]/Span<byte>, Array.Clear() for char[]. Use IDisposable/using for deterministic cleanup of sensitive buffers, and treat SecureString as legacy interop rather than a recommended general-purpose solution: Microsoft's guidance is against it for new development on .NET, not only on non-Windows platforms.
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;
}
}
Why this is vulnerable: _password = null drops the reference, not the data. The characters stay where they were until the heap is compacted and that region is reused, and because the .NET GC relocates surviving objects, a long-lived password can be copied to a new address on the way - leaving the old bytes intact and unreferenced.
Strings also cannot be pinned or overwritten through the public API, so there is no version of this code that clears in place. char[] and byte[] can be, either with Array.Clear() or through a span with Span<T>.Clear(). Note that CryptographicOperations.ZeroMemory() is not the general-purpose wipe it reads as - its only overload takes a Span<byte>, so it cannot be handed a char[] or a Span<char> and will not compile if you try. For byte[] buffers, prefer it over Array.Clear()/Span<T>.Clear() anyway: Microsoft's own remarks say the method exists "to future-proof against potential optimizations in the .NET runtime that could eliminate memory writes that aren't followed by memory reads" - exactly the shape of a finally-block clear on a buffer that goes out of scope right after - and neither Array.Clear() nor Span<T>.Clear() carries that guarantee.
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();
}
}
Why this is vulnerable: The field lives as long as the client does, and a client held in the dependency-injection container as a singleton lives as long as the process.
Getting the key out needs no attack. Windows Error Reporting can be configured to collect full dumps automatically, dotnet-dump is a supported diagnostic, and cloud crash-reporting agents upload before anyone reviews the contents - each producing a file where every managed string is readable. Treating a heap dump as sensitive is part of the fix, but the shorter version is not to keep the key resident once the request is signed.
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}");
}
}
Why this is vulnerable: Structured logging is what makes this harder to contain than plain text. ILogger keeps the message template and its arguments as separate fields so the sink can index them, which means the password arrives at the aggregator as a queryable property rather than as prose - and the destructuring operator @ serialises an entire object graph, so logging a request or options object sweeps in whatever it happens to hold.
Redaction filters configured by property name are the usual answer and are fragile in a specific way: they match the name they were told about, so a rename, a nested copy, or a new endpoint that calls the field something else passes straight through.
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;
}
}
Why this is vulnerable: SecureString holds its data in unmanaged memory that only Dispose() frees and zeroes. Skip it and the buffer survives until the finalizer runs, which is at a time the runtime chooses and possibly not before the process dumps.
The more useful conclusion is the one this page's Primary Defence already draws: on current .NET this is not a control worth repairing. Microsoft advises against SecureString in new code, and its encryption is a Windows behaviour - on Linux and macOS the contents are held without it. Where it appears in a finding because an interop API demands it, dispose it properly; where it appears because someone reached for it as the secure option, the fix is a cleared char[], not a using statement.
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);
}
}
}
Why this is vulnerable: This round trip produces two copies that outlive the intent. Marshal.SecureStringToBSTR() allocates unmanaged memory that the caller must free with Marshal.ZeroFreeBSTR() - Marshal.FreeBSTR() releases it without zeroing - and PtrToStringBSTR() then creates a managed string that cannot be cleared at all. Disposing the SecureString afterwards leaves both.
It also explains why the type buys so little in practice. Almost every consuming API takes a string, so real code performs this conversion somewhere, and the plaintext window is the whole time the value is in use rather than the instant of the call.
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 Microsoft's guidance discourages new use- Unmanaged memory: Unlike the managed heap, it can be overwritten before it is released
Marshal.ZeroFreeBSTR: Zeros that memory before releasing itfinally: The unmanaged copy is cleared even if an exception is thrown- Platform and API limitations: Protection is platform-dependent, and many APIs require plaintext conversion before use
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;
using System.Collections.Generic;
using System.Security.Cryptography;
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
{
// The stored record carries the per-user salt alongside the hash
(byte[] salt, byte[] storedHash) = GetStoredCredential(username);
byte[] hash = HashPassword(passwordBytes, salt);
return CryptographicOperations.FixedTimeEquals(hash, storedHash);
}
finally
{
// Clear byte array - ZeroMemory, not Array.Clear, so the write
// can't be optimized away now that nothing reads this buffer again
CryptographicOperations.ZeroMemory(passwordBytes);
}
}
finally
{
// Always clear char array
Array.Clear(password, 0, password.Length);
}
}
// PBKDF2-HMAC-SHA256 at the OWASP-recommended work factor. A general-purpose
// hash such as SHA-256 is the wrong primitive here: it is designed to be fast,
// which is exactly what an offline cracker wants.
private const int Iterations = 600_000;
private const int HashBytes = 32;
private byte[] HashPassword(byte[] passwordBytes, byte[] salt)
{
return Rfc2898DeriveBytes.Pbkdf2(
passwordBytes, salt, Iterations, HashAlgorithmName.SHA256, HashBytes);
}
// Your storage lookup. Both values are per-user and stored together; the
// salt is not secret, so it lives beside the hash rather than in code.
private (byte[] Salt, byte[] Hash) GetStoredCredential(string username)
=> _users[username];
private readonly Dictionary<string, (byte[] Salt, byte[] Hash)> _users = new();
}
Why this works:
- A password KDF, not a general-purpose hash:
Rfc2898DeriveBytes.Pbkdf2with 600,000 SHA-256 iterations follows the OWASP Password Storage Cheat Sheet. SHA-256 on its own is unsuitable here for the reason it is suitable elsewhere - it is fast, so an attacker holding the hashes tests candidates at GPU speed. Argon2id or bcrypt are preferable where a library is available; the choice here keeps the example to the framework - Per-user salt: the salt is read from the stored record rather than fixed in code, so identical passwords produce different hashes and one precomputed table cannot cover the user base
CryptographicOperations.FixedTimeEqualsfor the comparison: a length-independent equality check, so verification does not leak how much of the hash matchedchar[]is mutable and clearable:Array.Clear()zeros memory, unlike immutable strings that persist until GC- Nested
try-finallyensures complete cleanup: thechar[]is zeroed withArray.Clear(), and the intermediatebyte[]withCryptographicOperations.ZeroMemory(), which is guaranteed not to be optimized away - 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
SecureStringwhenever 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
CryptographicOperations.ZeroMemory(nonce);
CryptographicOperations.ZeroMemory(tag);
CryptographicOperations.ZeroMemory(ciphertext);
}
}
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.)
CryptographicOperations.ZeroMemory(plaintext);
throw;
}
finally
{
// Clear intermediate buffers for defense in depth
CryptographicOperations.ZeroMemory(nonce);
CryptographicOperations.ZeroMemory(tag);
CryptographicOperations.ZeroMemory(ciphertext);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (_keyBytes != null)
{
// Clear key from memory
CryptographicOperations.ZeroMemory(_keyBytes);
_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
CryptographicOperations.ZeroMemory(sessionKey);
}
// 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
CryptographicOperations.ZeroMemory(secretBytes);
}
}
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
CryptographicOperations.ZeroMemory(secretBytes);
}
}
private void Cleanup()
{
if (_disposed) return;
_disposed = true;
_keyManager?.Dispose();
}
}
using System;
using System.Security.Cryptography;
/// <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)
{
CryptographicOperations.ZeroMemory(_encryptedData);
_encryptedData = null;
}
_disposed = true;
}
}
// Your application code (usage example)
// Simulate password coming from another source, ready for storage
char[] password = "<redacted-password>".ToCharArray();
// Name the local something other than the type: `using var ProtectedSecret =
// ProtectedSecret.Consume(...)` is CS0841, because the initializer refers to a
// local that is not yet declared.
using var secret = ProtectedSecret.Consume(password);
// password[] is now all zeros - it was consumed!
// Use password safely with automatic cleanup
secret.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: The associated data binds a ciphertext to its context, so ciphertexts cannot be mixed and matched between contexts
ASP.NET Core authentication with secure password handling
What this example does not do: it does not convert the password to a
char[]. The model binder has already produced astringfrom the request body before the action runs, and every Identity API on the path -CheckPasswordSignInAsync,IPasswordHasher<T>.VerifyHashedPassword- takes astringas well. Arequest.Password.ToCharArray()here would add a plaintext copy, clear that copy, and leave the original untouched. That is the "boundary is the API you have to call" case from Considerations below, and the honest answer is to spend the effort on dump controls and process lifetime instead.
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
// A real hash of a value nobody knows, verified against when the username
// does not exist so that both branches cost the same.
private static readonly string DummyHash =
new PasswordHasher<ApplicationUser>()
.HashPassword(new ApplicationUser(), Guid.NewGuid().ToString());
public AuthController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var user = await _userManager.FindByNameAsync(request.Username);
if (user is null)
{
// Do the hashing work anyway and discard the result, so an unknown
// username is not distinguishable from a wrong password by timing.
_userManager.PasswordHasher.VerifyHashedPassword(
new ApplicationUser(), DummyHash, request.Password);
return Unauthorized(new { error = "Invalid credentials" });
}
var result = await _signInManager.CheckPasswordSignInAsync(
user,
request.Password,
lockoutOnFailure: true
);
return result.Succeeded
? Ok(new { success = true })
: Unauthorized(new { error = "Invalid credentials" });
}
}
public class LoginRequest
{
public required string Username { get; set; }
public required string Password { get; set; }
}
Why this works:
- ASP.NET Core Identity uses a password KDF: the default
PasswordHasher<T>is PBKDF2-HMAC-SHA256, so the stored value is not a fast general-purpose hash. Identity ships no bcrypt or Argon2 implementation - using either means registering your ownIPasswordHasher<TUser> - The request never becomes more copies than it arrived as: no
ToCharArray(), no second buffer to clear. On this path the only honest memory control is that theLoginRequestis not stored anywhere and goes out of scope when the action returns - Both branches pay the same cost: verifying the unknown-user case against a real hash closes the timing oracle that a bare early return opens. A generic message alone does not - the response body is identical either way while the response time is not, and an early return answers in microseconds against tens of milliseconds for a real verification
- The dummy hash is a genuine hash at the same work factor: verifying against
""ornullreturns immediately and reopens the gap the check was added to close - Built-in lockout:
lockoutOnFailure: trueis what makes the now-uniform cost affordable, since every login attempt runs the KDF whether the account exists or not
Secure credential storage with SafeHandle
using System;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using Microsoft.Win32.SafeHandles;
public sealed class SecureCredentialHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// The length has to be carried by the handle. Unmanaged memory does not
// record how large it is, so a ReleaseHandle that does not know the size
// cannot zero the buffer - which is the whole reason this type exists.
private readonly int _length;
public SecureCredentialHandle(IntPtr preexistingHandle, int length, bool ownsHandle)
: base(ownsHandle)
{
_length = length;
SetHandle(preexistingHandle);
}
protected override bool ReleaseHandle()
{
if (handle == IntPtr.Zero)
{
return false;
}
unsafe
{
// Zero first, then free. NativeMemory.Clear is not elided by the
// optimiser the way a hand-written loop over a dead buffer can be.
NativeMemory.Clear((void*)handle, (nuint)_length);
}
Marshal.FreeHGlobal(handle);
SetHandle(IntPtr.Zero);
return true;
}
}
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, credential.Length, true);
}
catch
{
// The copy above may already have run, so zero before freeing here
// too - the failure path must not be the one that leaks.
unsafe
{
NativeMemory.Clear((void*)ptr, (nuint)credential.Length);
}
Marshal.FreeHGlobal(ptr);
throw;
}
finally
{
// Clear original credential
CryptographicOperations.ZeroMemory(credential);
}
}
public void Dispose()
{
_credentialHandle?.Dispose();
}
}
Why this works:
SafeHandleensures automatic memory clearing:ReleaseHandle()zeros the buffer withNativeMemory.Clearand then frees it, which is why the handle stores the allocation length - unmanaged memory carries no size of its own, so a handle that does not record it can only free, never wipe- This example needs
<AllowUnsafeBlocks>:NativeMemory.Cleartakes a pointer. Where that is unacceptable,Marshal.Copyfrom a zeroed managed array achieves the same result withoutunsafe, at the cost of one more buffer - Unmanaged memory: Not subject to garbage collection copying or compaction
- Reference counting defers the free:
SafeHandletracks outstanding users, and the runtime takes a reference for you while the handle is marshalled into a native call. Measured on .NET 10: with oneDangerousAddRefoutstanding,Dispose()does not runReleaseHandle()- the buffer is zeroed and freed only once the last reference is released. A rawIntPtrhas nothing equivalent, which is what makes the wrapper worth its complexity once the credential is passed to native code - 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 signing key handling with explicit clearing
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
public class SecureJWTHandler : IDisposable
{
private byte[] _secretKey;
private bool _disposed = false;
public SecureJWTHandler(byte[] secretKey)
{
// Own a copy, so the caller can clear theirs without breaking signing
_secretKey = new byte[secretKey.Length];
Array.Copy(secretKey, _secretKey, secretKey.Length);
}
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)
{
CryptographicOperations.ZeroMemory(_secretKey);
_secretKey = null;
}
_disposed = true;
}
}
}
// Usage
public class TokenService
{
public string GenerateToken(string userId, byte[] secretKey)
{
using var jwtHandler = new SecureJWTHandler(secretKey);
try
{
return jwtHandler.CreateToken(userId);
}
finally
{
// Clear secret key
CryptographicOperations.ZeroMemory(secretKey);
}
}
}
Why this works:
SymmetricSecurityKeywraps the array rather than copying it: this is what makes the pattern work at all -CryptographicOperations.ZeroMemory(_secretKey)inDispose()zeroes the same bytes the key object is holding, so there is no second copy left behind insideMicrosoft.IdentityModel- The handler owns its copy: the constructor copies the caller's array, so the caller's
CryptographicOperations.ZeroMemory(secretKey)in the usage example does not break signing that is still in progress. Two independently clearable buffers, each cleared by whoever owns it, with a call that is guaranteed not to be optimized away - Key size is enforced: HS256 rejects a key under 256 bits with
IDX10720at signing time, so a truncated or short secret fails rather than producing a weak token ClockSkew = TimeSpan.Zeroremoves the five-minute default tolerance, so an expired token is refused when it expiresValidateIssuer/ValidateAudienceare off because this token carries neither claim. That is deliberate for a self-issued single-service token and wrong to copy into a service that accepts tokens from anywhere else: turn both on and setValidIssuer/ValidAudienceas soon as more than one issuer can reach the endpoint
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 is designed for password storage, with automatic salting, a configurable work factor (cost), and 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 a random salt and stores it with the hash in a single string, eliminating separate salt management - Configurable work factor: BCrypt.Net-Next 4.2 defaults to 11 rounds, so the hash carries a
$2a$11$prefix. Read the cost off a hash your own build produced rather than trusting a figure written down elsewhere - the default has moved before, and a hash created under an older default keeps its own cost until the password is changed - Constant-time verification:
Verify()compares in constant time, so the timing does not reveal how much of the hash matched; the brief plaintext string is an acceptable trade for a well-tested library over a hand-rolled PBKDF2 or Argon2
NetworkCredential with SecureString
using System;
using System.Net;
using System.Net.Http;
using System.Security;
public class SecureHttpClientFactory
{
// HttpClient rather than WebRequest/HttpWebRequest, obsolete since .NET 6
// (SYSLIB0014). One handler and one client, reused for the lifetime of the
// application - a client per request exhausts sockets.
public static HttpClient Create(string username, SecureString password)
{
var handler = new HttpClientHandler
{
// NetworkCredential accepts SecureString.
// Plaintext still exists at the authentication protocol boundary.
Credentials = new NetworkCredential(username, password)
};
return new HttpClient(handler);
}
}
// Usage
public void MakeRequest()
{
var password = new SecureString();
// Read a character at a time. Iterating a string that already holds the
// password - `foreach (char c in GetPasswordFromUser())` - would defeat the
// point: Microsoft's own guidance is that a SecureString should never be
// built from a String, because the plaintext is already on the managed heap
// by then and cannot be cleared.
ConsoleKeyInfo key;
while ((key = Console.ReadKey(intercept: true)).Key != ConsoleKey.Enter)
{
if (key.Key == ConsoleKey.Backspace)
{
if (password.Length > 0) password.RemoveAt(password.Length - 1);
}
else if (!char.IsControl(key.KeyChar))
{
password.AppendChar(key.KeyChar);
}
}
Console.WriteLine();
// Make password read-only
password.MakeReadOnly();
try
{
// One client for the lifetime of the application - see the note above
HttpClient client = SecureHttpClientFactory.Create("username", password);
// Use client
}
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 one complete immutable string only if the source is character-at-a-time.Console.ReadKey(intercept: true)is such a source; a helper returning astringis not, and building aSecureStringfrom one is the case Microsoft's documentation singles out as pointless - 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
Considerations
This is a mitigation, not an elimination, and the difference matters when deciding how far to go. A managed runtime gives you no way to guarantee a secret is gone: the garbage collector copies values as it compacts, immutable strings cannot be overwritten at all, pages may be written to swap, and a crash dump captures whatever happens to be resident. Clearing buffers shortens the window an attacker with memory access must hit. It does not close it. Say which you are buying before spending much effort.
The boundary is the API you have to call. Holding a credential in a mutable buffer only helps if everything downstream accepts one. The moment a library requires a string, the conversion creates a copy you cannot clear, and the care taken upstream buys almost nothing. Judge by whether the whole path can avoid the conversion; if it cannot, spend the effort on the operational controls instead.
SecureString is not the answer, and Microsoft says so. The documentation
states plainly that it should not be used for new development on .NET, and the
reasons are worth knowing: its contents are only encrypted on Windows, every
use has to decrypt back to plain text, and interop conversions allocate an
unmanaged clear-text copy you must zero yourself. Microsoft's recommended
alternative is an opaque handle to a credential stored outside the process,
which is the same conclusion as the point above.
Most of the real exposure is operational rather than in the code. Whether process dumps are enabled, whether swap is encrypted, whether the host is shared, how long worker processes live, and whether debuggers can attach in production will usually change the risk more than any in-process buffer handling. If you can only do one thing, restricting dump generation and shortening process lifetime tends to beat clearing arrays.
The strongest version of this fix is not holding the secret at all. Fetching a credential from a vault at the point of use, keeping it for the shortest span the operation needs, and letting the platform hold anything long-lived removes the question rather than managing it.
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
- CryptographicOperations.ZeroMemory Method - the future-proofed
byte[]/Span<byte>clear - Array.Clear Method - use for
char[], whichZeroMemorycannot accept - SafeHandle Class
- Data Protection API
- CWE-316: Cleartext Storage of Sensitive Information in Memory
- OWASP Secure Coding Practices