DEV Community

Cover image for Memory-Safe Secrets in .NET Configuration
bwi
bwi

Posted on

Memory-Safe Secrets in .NET Configuration

In Why Strings Are Not Safe for Secrets, we explored why strings are fundamentally unsafe for secrets — they linger in memory, survive GC cycles, and appear in dumps and swap files.

The conclusion was pragmatic but unsatisfying: there's no perfect solution — but you can minimize exposure by keeping secrets encrypted until the last possible moment.

We recently released Cocoar.Configuration.Secrets — an extension to Cocoar.Configuration — that makes this pattern the default, not an afterthought.


🧵 This is Part 3 of the Cocoar.Configuration Series:

📖 Related Reading: Why Strings Are Not Safe for Secrets (standalone security article)

⚠️ Developer Preview: This feature is production-ready and ships in v4.0.0, but marked as Developer Preview because the API may evolve based on real-world feedback. Breaking changes are possible in future releases.


🎯 What We Built

Cocoar.Configuration.Secrets provides a combination of capabilities that we haven't seen in any other open-source .NET configuration library:

  1. Secret<T> type — Memory-safe wrapper that automatically zeroes secrets after use
  2. Hybrid encryption — RSA + AES-GCM for strong encryption at rest with X.509 certificates
  3. CLI tooling — Generate certificates, encrypt secrets, manage formats — all password-less by default

The result: secrets stay encrypted in configuration files, in memory, and are only decrypted for microseconds when actually needed.

Attack window reduction: From minutes/hours to microseconds.


💡 Looking Ahead: Local encrypted files are just the beginning. We're building ConfigHub — a centralized configuration management platform that eliminates local secret storage entirely.
Secrets will be encrypted at the source, distributed in real-time via SignalR, and never touch disk on your application servers.
The encryption patterns and Secret<T> type you see here are the foundation for that vision. More details in the "What's Next" section below.


⚠️ A Note on “Memory-Safe”

When we talk about memory-safe secrets, we are referring specifically to managed memory inside the .NET runtime:

  • secrets remain encrypted until explicitly opened
  • decrypted values live only inside controlled, short-lived buffers
  • buffers are zeroed immediately after use

This drastically reduces the attack window compared to traditional string-based configuration.

However, no general-purpose application can guarantee total system-wide memory secrecy:

  • CPU-level caches
  • kernel buffers
  • JIT optimizations
  • OS-level paging/swapping

…are not under the control of any .NET library.

The goal of Secret<T> is therefore not “perfect secrecy”, but to minimize exposure in managed memory to the smallest realistic window — microseconds instead of minutes or hours.


🔐 The Secret<T> Type

Instead of storing secrets as strings:

// ❌ Traditional approach
public class AppSettings
{
    public string DatabasePassword { get; set; }  // Lives forever
    public string ApiKey { get; set; }            // Lives forever
}
Enter fullscreen mode Exit fullscreen mode

Use Secret<T>:

// ✅ Memory-safe approach
using Cocoar.Configuration.Secrets.SecretTypes;

public class AppSettings
{
    public Secret<string> DatabasePassword { get; set; }
    public Secret<string> ApiKey { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Access secrets with controlled exposure:

// .ToString() always returns "***" and never exposes the real value
var password = config.DatabasePassword.ToString();

// Correct - explicit, scoped access
using (var lease = config.DatabasePassword.Open())
{
    var connStr = $"Server=db;User=admin;Password={lease.Value}";
    await database.ConnectAsync(connStr);
}
// ✅ Memory automatically zeroed after 'using' block
Enter fullscreen mode Exit fullscreen mode

Key behaviors:

  • ToString() returns "***" — prevents accidental logging
  • .Open() returns a SecretLease<T> — disposable wrapper around the value
  • Automatic zeroization on dispose — memory cleared immediately
  • Type-safe — works with primitives, complex objects, collections

🔒 How It Works: Hybrid Encryption

Secrets are stored as encrypted envelopes in your configuration files using a hybrid encryption scheme:

  1. AES-256-GCM encrypts your secret (fast, symmetric)
  2. RSA-OAEP encrypts the AES key (secure key wrapping)
  3. X.509 certificate holds the RSA keys (industry-standard format)

This is the same approach used by AWS KMS, Azure Key Vault, Google Cloud KMS, and HashiCorp Vault.

In your JSON:

{
  "Database": {
    "Host": "localhost",
    "Port": 5432,
    "Password": {
      "type": "cocoar.secret",
      "version": 1,
      "kid": "prod-db",
      "alg": "RSA-OAEP-AES256-GCM",
      "ciphertext": "Y2lwaGVydGV4dF9iYXNlNjQ=...",
      "key": "ZW5jcnlwdGVkX2tleV9iYXNlNjQ=...",
      "nonce": "bm9uY2VfYmFzZTY0",
      "tag": "dGFnX2Jhc2U2NA=="
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The type: "cocoar.secret" marker tells the deserializer this is an encrypted envelope, not plaintext.

At runtime:

  1. ConfigManager deserializes the envelope (still encrypted)
  2. Secret stays encrypted in memory as Secret<T>
  3. When you call .Open(), it decrypts just-in-time
  4. When you dispose the lease, memory is zeroed

🤔 Why Not Just Extend Microsoft.Extensions.Configuration?

A common question many experienced .NET developers have is:

“Why can’t this just work with Microsoft.Extensions.Configuration or IOptions?”

Short answer: because the Microsoft configuration pipeline is string‑based at its core.
Once a secret enters that system, it has already been converted to plaintext — and it will remain in managed memory for the lifetime of the application.

There is no point in the pipeline where you can enforce:

  • “this value must never become a string”,
  • “keep this encrypted until I explicitly open it”,
  • “zeroize the buffer after use”, or
  • “deserialize this into a strong type without passing through a string buffer”.

Secret<T> only works because the encrypted envelope stays encrypted through the entire configuration flow, from:

JSON → deserialization → typed config object
Enter fullscreen mode Exit fullscreen mode

without ever becoming plaintext until .Open() is called.

Microsoft.Extensions.Configuration is not designed for this; it fundamentally cannot support non‑string types that require controlled, on‑demand decryption.

This is exactly why Cocoar.Configuration exists:

  • it preserves strong types end‑to‑end,
  • avoids string-based intermediate storage,
  • allows encrypted envelopes to remain encrypted in memory, and
  • enables safe abstractions like Secret<T>.

There is no extension point in the Microsoft config system that could retrofit this behavior safely — it requires a different architecture.

(Don't worry — migrating from IOptions to Cocoar.Configuration is straightforward, explained in Part 1)


🚀 Getting Started

Installation

dotnet add package Cocoar.Configuration.Secrets
dotnet tool install --global Cocoar.Configuration.Secrets.Cli
Enter fullscreen mode Exit fullscreen mode

Step 1: Generate a Certificate

cocoar-secrets generate-cert --output prod-cert.pfx --subject "CN=Production Database"
Enter fullscreen mode Exit fullscreen mode

This creates a password-less certificate protected by file permissions — the industry standard approach used by nginx, PostgreSQL, Kubernetes, and Docker.

Security setup:

# Linux/macOS
chmod 600 prod-cert.pfx && chown app-user prod-cert.pfx

# Windows
icacls prod-cert.pfx /inheritance:r /grant:r "AppUser:(R)"
Enter fullscreen mode Exit fullscreen mode

⚠️ Important: The runtime library ONLY supports password-less certificates. If you have a password-protected certificate, use cocoar-secrets convert-cert to remove the password first.

Step 2: Encrypt Your Secrets

cocoar-secrets encrypt \
  --file appsettings.json \
  --cert prod-cert.pfx \
  --kid prod-db \
  --path "Database.Password"
Enter fullscreen mode Exit fullscreen mode

This modifies your JSON in-place, replacing plaintext with an encrypted envelope.

Step 3: Configure Your App

using Cocoar.Configuration;
using Cocoar.Configuration.Secrets;
using Cocoar.Configuration.Secrets.SecretTypes;

public class DatabaseConfig
{
    public string Host { get; set; }
    public int Port { get; set; }
    public Secret<string> Password { get; set; }
}

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCocoarConfiguration(rule => [
    rule.For<DatabaseConfig>().FromFile("appsettings.json").Select("Database")
], setup => [
    setup.Secrets().UseCertificateFromFile("prod-cert.pfx").WithKeyId("prod-db")
]);

var app = builder.Build();

app.MapPost("/connect", async (DatabaseConfig config) =>
{
    using var passwordLease = config.Password.Open();
    var connStr = $"Host={config.Host};Port={config.Port};Password={passwordLease.Value}";

    await database.ConnectAsync(connStr);
    return Results.Ok("Connected");
});

app.Run();
Enter fullscreen mode Exit fullscreen mode

(Cocoar.Configuration does not use IOptions or IOptionsSnapshot — it injects typed config directly. The equivalent of IOptionsSnapshot is IReactiveConfig introduced in Part 1.)

That's it. Secrets are now encrypted at rest, encrypted in memory, and only decrypted for microseconds.


🛠️ CLI Tool Overview

The Cocoar.Configuration.Secrets.Cli tool provides everything you need:

Generate Certificates

# PFX format (password-less)
cocoar-secrets generate-cert --output cert.pfx --subject "CN=My App"

# PEM format (certificate + separate key file)
cocoar-secrets generate-cert --output cert.pem --format pem
# Creates: cert.pem + cert.key

# With custom parameters
cocoar-secrets generate-cert \
  --output cert.pfx \
  --subject "CN=Production API" \
  --valid-years 2 \
  --key-size 4096
Enter fullscreen mode Exit fullscreen mode

Convert Between Formats

# Remove password from existing certificate
cocoar-secrets convert-cert \
  --input old-cert.pfx \
  --input-password oldPass123 \
  --output new-cert.pfx

# Convert PFX to PEM
cocoar-secrets convert-cert --input cert.pfx --output cert.pem
Enter fullscreen mode Exit fullscreen mode

Encrypt/Decrypt Secrets

# Encrypt a value
cocoar-secrets encrypt \
  --file appsettings.json \
  --cert cert.pfx \
  --kid prod \
  --path "Api.Key"

# Encrypt multiple paths at once
cocoar-secrets encrypt \
  --file appsettings.json \
  --cert cert.pfx \
  --kid prod \
  --path "Database.Password" \
  --path "Api.Key" \
  --path "Smtp.Password"

# Decrypt a value (for verification)
cocoar-secrets decrypt \
  --file appsettings.json \
  --cert cert.pfx \
  --path "Api.Key"
Enter fullscreen mode Exit fullscreen mode

🏗️ Production Patterns

Pattern 1: Folder-Based Multi-Tenant

/app/secrets/
  ├── tenant-1/cert.pfx
  ├── tenant-2/cert.pfx
  └── tenant-3/cert.pfx
Enter fullscreen mode Exit fullscreen mode
setup.Secrets()
    .UseCertificatesFromFolder("/app/secrets", "*.pfx", maxDepth: 1);
Enter fullscreen mode Exit fullscreen mode

The kid in the envelope maps to the folder name.

Pattern 2: Complex Secret Types

Secrets work with any JSON-serializable type:

public class DatabaseCredentials
{
    public string Username { get; set; }
    public string Password { get; set; }
    public string ConnectionString { get; set; }
}

public class AppConfig
{
    public Secret<DatabaseCredentials> Database { get; set; }
}

// Encrypt the entire object
cocoar-secrets encrypt --file config.json --cert cert.pfx --kid prod --path "Database"

// Use it
using var lease = config.Database.Open();
await db.ConnectAsync(lease.Value.ConnectionString);
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Collections of Secrets

public class ApiConfig
{
    public Secret<string[]> AllowedApiKeys { get; set; }
}

using var lease = config.AllowedApiKeys.Open();
if (lease.Value.Contains(requestApiKey))
{
    // authorized
}
Enter fullscreen mode Exit fullscreen mode

🔄 Development Workflow

Local Development

Use plaintext for faster iteration:

// appsettings.Development.json
{
  "Database": {
    "Password": "local-dev-password"
  }
}
Enter fullscreen mode Exit fullscreen mode

The library detects plaintext and wraps it in Secret<T>, but logs a warning:

⚠️  Secret<string> was deserialized from plaintext instead of an encrypted envelope.
    This is OK for local development but should never happen in production.
Enter fullscreen mode Exit fullscreen mode

Production

Same property contains an encrypted envelope — no code changes needed:

// appsettings.Production.json
{
  "Database": {
    "Password": {
      "type": "cocoar.secret",
      "version": 1,
      "kid": "prod-db",
      "alg": "RSA-OAEP-AES256-GCM",
      "ciphertext": "...",
      "key": "...",
      "nonce": "...",
      "tag": "..."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

💭 Design Decisions

Why X.509 Certificates?

  • Industry standard — TLS/SSL, code signing, S/MIME, Kubernetes
  • Cross-platform — Windows, Linux, macOS
  • Native OS integration — Certificate Store, Keychain
  • Rich ecosystem — OpenSSL, certbot, Azure Key Vault, AWS Certificate Manager

Why Only Password-Less Certificates?

The password bootstrapping problem: If your certificate has a password, where do you store that password?

Solution: File permissions + full-disk encryption — This is how nginx, PostgreSQL, Kubernetes, and Docker work:

  1. File permissions — Only app user can read: chmod 600 cert.pfx
  2. Full-disk encryption — BitLocker, LUKS, FileVault
  3. Access control — OS-level authentication required

If an attacker can read your certificate file, they already have OS-level access to your app's memory — game over anyway. Password protection adds ceremony without meaningful security gain.

Why Hybrid Encryption?

  • RSA alone — Slow, size limits (~256 bytes for 2048-bit keys)
  • AES alone — Need secure key distribution, complex rotation
  • Hybrid (RSA + AES) — Fast encryption (AES), secure distribution (RSA), no limits

This is the gold standard used by AWS KMS, Azure Key Vault, and GCP KMS.


🎯 Real-World Impact

Before Cocoar.Configuration.Secrets:

public class DbConfig
{
    public string Password { get; set; }  // Plaintext in memory for hours
}
Enter fullscreen mode Exit fullscreen mode

Attack surface: 100% of application uptime

After Cocoar.Configuration.Secrets:

public class DbConfig
{
    public Secret<string> Password { get; set; }  // Encrypted until needed
}

using var lease = config.Password.Open();
await db.ConnectAsync($"...Password={lease.Value}");
Enter fullscreen mode Exit fullscreen mode

Attack surface: Microseconds per operation

Metric Traditional String Cocoar.Configuration.Secrets
Plaintext lifetime 24 hours ~50 seconds total
Attack window 100% 0.06%
Risk reduction Baseline 99.94% reduction

🔮 What's Next - The Bigger Picture: ConfigHub

Cocoar.Configuration.Secrets is the foundation. ConfigHub is the destination.

Imagine:

  • Web UI for managing configs across all environments and services
  • Real-time push via SignalR—update 100 instances in seconds, not hours
  • Zero local files—secrets never touch disk on application servers
  • Audit trails—who changed what secret, when, and why
  • Role-based access—developers can't see production secrets
  • Encryption at source—secrets encrypted before leaving ConfigHub

The Secret<T> type, certificate management, and encrypted envelopes you're learning today are building blocks for that platform.

We're designing ConfigHub now. The v4.0.0 encryption architecture ensures we can deliver on that vision without breaking changes.

Target audience: Teams managing 10+ services where configuration complexity is a bottleneck.

Stay tuned. 📡


🚀 Try It Today

# Install packages
dotnet add package Cocoar.Configuration.Secrets
dotnet tool install --global Cocoar.Configuration.Secrets.Cli

# Generate certificate
cocoar-secrets generate-cert --output cert.pfx --subject "CN=My App"

# Encrypt a secret
cocoar-secrets encrypt \
  --file appsettings.json \
  --cert cert.pfx \
  --kid prod \
  --path "Database.Password"

# Run your app
dotnet run
Enter fullscreen mode Exit fullscreen mode

Resources:


💬 Feedback Welcome

The Secrets feature is in Developer Preview (v4.0.0) — the implementation is production-ready and tested, but the API surface may evolve based on real-world usage patterns.

Tell us:

  • What scenarios are we missing?
  • Which APIs feel awkward?
  • What documentation needs clarification?

Open an issue or start a discussion on GitHub. Your feedback directly shapes the 4.x roadmap.


Next in the series: Part 4 will cover the new Testing Infrastructure and Roslyn Analyzers that shipped with v4.0.0.


Thanks for reading! If this was helpful, leave a comment or share with your team. 🔒


Built with security, pragmatism, and developer experience in mind.
— Bernhard / @cocoar-dev

Top comments (0)