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:
- Part 1: Introducing Cocoar.Configuration
- Part 2: Config-Aware Rules
- Part 3: Memory-Safe Secrets ← you are here
- Part 4: Testing & Quality (coming soon)
📖 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:
-
Secret<T>type — Memory-safe wrapper that automatically zeroes secrets after use - Hybrid encryption — RSA + AES-GCM for strong encryption at rest with X.509 certificates
- 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 andSecret<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
}
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; }
}
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
Key behaviors:
-
ToString()returns"***"— prevents accidental logging -
.Open()returns aSecretLease<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:
- AES-256-GCM encrypts your secret (fast, symmetric)
- RSA-OAEP encrypts the AES key (secure key wrapping)
- 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=="
}
}
}
The type: "cocoar.secret" marker tells the deserializer this is an encrypted envelope, not plaintext.
At runtime:
- ConfigManager deserializes the envelope (still encrypted)
- Secret stays encrypted in memory as
Secret<T> - When you call
.Open(), it decrypts just-in-time - 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
…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
Step 1: Generate a Certificate
cocoar-secrets generate-cert --output prod-cert.pfx --subject "CN=Production Database"
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)"
⚠️ 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"
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();
(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
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
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"
🏗️ Production Patterns
Pattern 1: Folder-Based Multi-Tenant
/app/secrets/
├── tenant-1/cert.pfx
├── tenant-2/cert.pfx
└── tenant-3/cert.pfx
setup.Secrets()
.UseCertificatesFromFolder("/app/secrets", "*.pfx", maxDepth: 1);
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);
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
}
🔄 Development Workflow
Local Development
Use plaintext for faster iteration:
// appsettings.Development.json
{
"Database": {
"Password": "local-dev-password"
}
}
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.
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": "..."
}
}
}
💭 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:
-
File permissions — Only app user can read:
chmod 600 cert.pfx - Full-disk encryption — BitLocker, LUKS, FileVault
- 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
}
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}");
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
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)