Secrets in .NET: Why Strings Are Not Safe (and What to Do Instead)
Most .NET applications start simple: load secrets from configuration, bind them to IOptions<T>, and pass them around as strings.
But here’s the uncomfortable truth: strings are the worst possible type for holding secrets.
This isn’t just about avoiding strings — it’s about understanding why they’re dangerous, what actually happens in memory, and how to reduce exposure without chasing impossible security guarantees.
💣 The Problem with Strings
A string in .NET looks harmless, but for sensitive data, it’s a liability waiting to happen.
- Immutable — once created, it can never be changed or cleared
- Managed by the GC — you can’t control when (or if) it’s wiped from memory
- Interned (sometimes) — the runtime may cache identical strings in a shared pool, meaning they persist beyond your variable’s scope
- Moved during compaction — when the GC compacts the heap, it moves objects to new memory locations. The old memory may still contain remnants until overwritten, so your secret may transiently exist in more than one area of RAM
If your application crashes and someone captures a memory dump, every live string is sitting there in plaintext — API keys, tokens, connection strings, passwords.
⚠️ Constants and Static Fields
private const string ApiKey = "secret123"; // ❌ Never do this
private static readonly string Token = "abc"; // ❌ Also problematic
Constants and static readonly strings are automatically interned and live for your application’s entire lifetime. They can never be cleared.
Anything baked into the binary (consts, literals) is effectively permanent.
If it’s in IL or metadata, assume it’s recoverable.
🖴 The Page File Problem
Even if you clear a secret from RAM, the operating system might have already paged it to disk — the swap file, hibernation file, or crash dump.
Once that happens, it’s outside your application’s control. The data could persist indefinitely.
This is why:
- Short lifetimes matter — less time in memory means less chance of being paged
- OS-level protection — encrypted swap, BitLocker, or full-disk encryption becomes critical for production systems
🧠 Debuggers, Diagnostics, and Memory Dumps
During development, Visual Studio and diagnostic tools like dotnet-dump or PerfView can inspect all strings in memory.
In production, crash dumps and memory dumps capture the entire process state — including all live strings.
This is normal and useful for debugging, but it means: if it’s a string, it’s visible to anyone with process access or dump analysis tools.
🧩 A Quick Example
// ❌ Typical approach
var apiKey = configuration["ApiKey"];
// This string can live in memory indefinitely
// ✅ Safer approach
var apiKeyBytes = GetSecretAsBytes("ApiKey");
try {
// Use it, then clear it
}
finally {
Array.Clear(apiKeyBytes);
}
The difference: “the secret just sits there” versus “the secret lives only as long as absolutely necessary.”
🎯 Setting Realistic Expectations
Before we dive into solutions, let’s establish what’s actually possible — and what isn’t.
🧠 The Real Threat Model
If an attacker gains control over your process, no in-memory trick will save you.
They can do everything your application can:
- Dump process memory
- Read certificates or DPAPI keys
- Intercept function calls
- Hook into the runtime
The realistic goal isn’t “absolute protection.”
It’s risk minimization — limit how long secrets live, how far they spread, and how easy they are to capture.
🤔 Why You Can’t Just Avoid Strings
You will need strings eventually:
- To build an HTTP
Authorizationheader - To open a database connection
- To load a certificate or decrypt a file
Some APIs help — for example, X509Certificate2(byte[]) can load from bytes — but many critical surfaces still require strings (like HTTP headers and connection strings).
The goal isn’t no strings ever.
The goal is to minimize their lifetime and reduce exposure.
The Last-Mile Problem
Even when you use byte[] or Span<byte>, you’ll eventually need to convert back to a string at the point of use:
var buffer = GetSecretBytes();
try {
var secretStr = Encoding.UTF8.GetString(buffer);
try {
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", secretStr);
await httpClient.SendAsync(request);
}
finally {
// Can't clear secretStr, but the scope is tight
}
}
finally {
Array.Clear(buffer); // ✅ At least the bytes are cleared
}
This is pragmatic security: you can’t eliminate the string, but you can keep its lifetime microscopic.
So if strings are inevitable and tokens aren’t magic, what about the “secure” alternatives you’ve heard about — and why they don’t really solve the problem?
❓ What About SecureString?
SecureString was supposed to solve this problem — until it didn’t.
It’s obsolete in modern .NET. Even Microsoft recommends against it, because most APIs still require a
string, forcing you to convert it back — which defeats the purpose.
The better approach today: use byte[], Span<byte>, or ReadOnlyMemory<byte>, and manage lifetimes explicitly.
🔑 Tokens Are Still Secrets
You often hear this claim:
“Our app never sees your credentials — we only use a token.”
That sounds safer, but let’s be honest: the token is the credential.
If someone steals the token, they can do everything you can do — no password required. It might not look like a password, but from an attacker’s perspective, it works exactly the same.
The only reason tokens are usually safer is how they’re managed, not what they are:
- Shorter lifetime — they expire sooner
- Scoped access — they grant limited permissions
- Revocable — you can disable a token without locking the entire account
That’s the real benefit: control and containment, not invulnerability.
If you issue an API key or a token valid for a year, you’ve basically reinvented a long-lived password — just with JSON in front of it.
So yes, tokens are great — but only if you treat them with the same respect as passwords: keep them secret, limit their lifetime, and clear them from memory as soon as possible.
🤷 “But What’s the Alternative?”
Let’s be brutally honest: there often isn’t one.
If you need to open a database connection, the API signature is:
public SqlConnection(string connectionString)
Not ReadOnlySpan<byte>. Not SecureString. String.
You have exactly three options:
Option 1: Don’t Use Secrets (Not Realistic)
Just… don’t have a database password? Don’t need API keys?
Yeah, that’s not happening.
Option 2: Wait for Every API to Change (Not Realistic)
Convince Microsoft, AWS, Stripe, and thousands of library authors to rewrite their APIs to accept ReadOnlySpan<byte> or similar.
Good luck — that’ll take a decade, if ever.
Option 3: Minimize the Window (Pragmatic) ✅
Accept that you must create the string at the point of use, but:
- Keep it encrypted until that exact moment
- Create the string in the tightest possible scope
- Clear the byte buffer immediately after
- Let the string die as quickly as possible
This is the only realistic option.
🧰 What You Can Do
Let’s break this down into principles, techniques, and pitfalls.
🧭 Principles
- Minimize lifetime — keep secrets in memory for as short a time as possible
-
Minimize conversions — every encoding, concatenation, or
.ToString()creates new allocations - Minimize scope — secrets should never escape their immediate context
🛠 Techniques
Use Clearable Memory
var buffer = Encoding.UTF8.GetBytes(secret);
try {
// use buffer
}
finally {
Array.Clear(buffer);
GC.KeepAlive(buffer); // Prevents JIT from optimizing Clear() away
}
Why
GC.KeepAlive()? The JIT may removeArray.Clear()if it deems the array dead.KeepAliveprevents that optimization.
Use Span<byte> or stackalloc for Short-Lived Secrets
Span<byte> secret = stackalloc byte[32];
try {
FillSecretFromVault(secret);
UseSecret(secret);
}
finally {
secret.Clear(); // Zeroes stack memory
}
Benefits: avoids heap allocation entirely, not subject to GC compaction, automatically cleared when scope ends.
Limitations: small secrets only, cannot escape the method, and stack pages can still be paged under extreme memory pressure (though far less likely than heap allocations).
Dispose Early
Scope secrets tightly using using blocks or disposable wrappers:
using var handle = GetSecretHandle("ApiKey");
// Secret is zeroed on Dispose
Fetch Just-in-Time
// ❌ Load all secrets at startup
services.Configure<Secrets>(config.GetSection("Secrets"));
// Secrets live in IOptions<Secrets> for the app's lifetime
// ✅ Fetch on demand
public async Task<User> AuthenticateAsync(string userId)
{
var secretBytes = await FetchSecretFromVault("api-key");
try {
var secretStr = Encoding.UTF8.GetString(secretBytes);
// Use secret
}
finally {
Array.Clear(secretBytes);
}
}
Retrieve or decrypt secrets only when necessary, then discard them immediately.
Use OS-Level Protection
Windows Credential Manager, DPAPI, Azure Key Vault, or HashiCorp Vault are excellent for storage — but once you fetch a secret, the in-memory problem returns.
They reduce how often your app directly touches secrets, but they don’t eliminate runtime exposure.
💥 Pitfalls to Avoid
📝 The Logging Trap
The most common real-world secret leak isn’t memory dumps — it’s accidental logging.
// ❌ This destroys all your careful memory management
logger.LogDebug("Connection string: {connStr}", connStr);
// Your secret is now in Seq, Splunk, Application Insights, or wherever
Solutions:
- Use custom types whose
ToString()returns"[REDACTED]" - Mark sensitive properties with
[JsonIgnore]or similar - Configure structured logging to exclude known secret fields
- Review logging configurations regularly
public sealed class SecretString
{
private readonly string _value;
public SecretString(string value) => _value = value;
public override string ToString() => "[REDACTED]";
public string Reveal() => _value; // use only at the last mile
}
🔐 Accidental Serialization
// ❌ Secret ends up in error reports, diagnostics, telemetry
var json = JsonSerializer.Serialize(options);
Be explicit about what gets serialized. Never blindly log or dump configuration objects.
🔗 Connection Strings: A Special Case
Connection strings are composite secrets — multiple sensitive values in one string:
"Server=db.example.com;Database=prod;User=admin;Password=secret123"
You have two options:
- Treat the entire string as one secret — simpler, but the password is tied to metadata
-
Parse it and treat only
Password=as a secret — complex, requires reconstruction
Most libraries force option 1 because connection string parsers expect the full string.
API design determines how safely you can handle secrets.
⚖️ What Helps (But Isn’t Perfect)
Before we look at the pragmatic strategy, one approach deserves attention: in-memory encryption.
“I’ll encrypt the string in memory.”
Reality: Encryption delays exposure; it doesn’t eliminate it.
Pros:
- Reduced exposure window (encrypted most of the time)
- Better crash-dump posture (ciphertext, not plaintext)
- Accidental logging less dangerous
Cons:
- Plaintext still required at use
- CPU overhead per access
- Exists briefly as string during use
Worth it?
Yes for high-value secrets (API keys, DB creds, OAuth secrets, encryption keys).
Maybe not for low-value configuration.
It’s pragmatic defense-in-depth — not a silver bullet.
🎯 The Pragmatic Strategy
After all that theory, here’s what the real-world approach actually looks like:
// ❌ Traditional approach - string lives for application lifetime
public class AppSettings
{
public string ConnectionString { get; set; }
}
services.Configure<AppSettings>(config.GetSection("Database"));
Lifetime: hours/days
Attack window: 100% uptime
// ✅ Better approach - string lives for microseconds
public class SecretConnectionString
{
private readonly byte[] _encrypted;
public SecretConnectionString(string plaintext) =>
_encrypted = EncryptInMemory(plaintext);
public string Open()
{
var bytes = DecryptInMemory(_encrypted);
try { return Encoding.UTF8.GetString(bytes); }
finally { Array.Clear(bytes); }
}
public override string ToString() => "***";
}
Lifetime: microseconds (only during connection creation)
Attack window: attacker must capture memory during active use
📊 The Realistic Improvement
Scenario: Application runs for 24 hours, database operations happen 1,000 times per day, each taking ~10 ms to open a connection.
| Approach | Plaintext Lifetime | Attack Window |
|---|---|---|
Traditional IOptions<string> |
24 hours (86,400 s) | 100% of uptime |
| Encrypted wrapper | ~10 seconds total | 0.01% of uptime |
You’ve reduced the attack surface by 99.99% — not by eliminating the string, but by minimizing when it exists.
🧱 What Inspired This
I first dug into all of this while building a Secrets feature for my Cocoar.Configuration library.
That work forced me to rethink how .NET applications handle sensitive data — not just how to store secrets, but how to live safely with them in memory.
The feature is still in development, but it’s being designed around the same principles described here:
- Minimized lifetime and scope
- Disposable, zeroable handles
- Lazy resolution and byte-based memory management
It’s not just a “wrapper for configuration secrets” — it’s an attempt to make safer secret handling the default, not an afterthought.
🔒 The Bottom Line
Perfect security doesn’t exist. But “not perfect” ≠ “not worth doing.”
Minimizing plaintext lifetime transforms the attack window from any time in 24 hours to microseconds during active use.
That’s orders of magnitude better — and in security, orders of magnitude matter.
Building this correctly means thinking at the library level, not with ad-hoc utility methods.
Thread safety, cross-platform support, proper disposal, and vault integration all matter for production-grade handling.
Stay secure. Stay pragmatic. 🔒