Skip to main content

PostgreSQL Provider

PostgreSQL .NET 10.0

Overview​

The PostgreSQL provider stores secrets in a PostgreSQL database with optional AES-256-GCM encryption at rest. It uses Npgsql with raw SQL for lightweight, direct database access -- no ORM overhead.

Key Benefits:

  • Use your existing PostgreSQL infrastructure for secrets
  • AES-256-GCM encryption at rest (optional)
  • Auto-versioning with per-secret version history
  • Optional table auto-creation, with an ownership check on the tables it uses
  • Tags stored as JSONB for flexible querying

Installation​

Terminal
dotnet add package SecretsManager
dotnet add package SecretsManager.PostgreSql

Configuration​

Options Reference​

OptionTypeRequiredDefaultDescription
ConnectionStringstringYes""PostgreSQL connection string, e.g. Host=localhost;Database=secrets;Username=app;Password=pass
SchemastringNo"public"Database schema for the tables
TablePrefixstringNo"sm_"Prefix for table names, to avoid collisions
AutoCreateSchemaboolNofalseCreate the tables automatically on first use. Requires CREATE on the schema
SchemaOwnerstring?NonullRole expected to own the secrets tables. When null, the connected role must own them
EncryptionKeystring?NonullBase64-encoded AES-256 key (32 bytes). When set, values are encrypted at rest with AES-256-GCM
EncryptionKeyFilestring?NonullPath to a file containing the encryption key. A 32-byte file is used as-is, otherwise it is treated as base64
EncryptionKeyProviderIEncryptionKeyProvider?NonullSupplies the encryption key from an external source, for example another secret provider. Fluent API only — it is an object, so it cannot come from appsettings.json, YAML, or an environment variable

PostgreSqlOptions.Validate() requires ConnectionString, rejects EncryptionKey, EncryptionKeyFile and EncryptionKeyProvider being set together, and caps TablePrefix at 40 characters (PostgreSQL truncates identifiers at 63 bytes, which can collapse the two table names into one). Validation runs on every construction path.

Fluent API​

Program.cs
var provider = new SecretProviderBuilder()
.WithPostgreSql(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=app;Password=secret";
options.Schema = "public";
options.TablePrefix = "sm_";
options.EncryptionKey = "base64-encoded-32-byte-key";
})
.Build();

With the Key From Another Provider​

The key can be held in a different secret provider entirely — Azure Key Vault, Scaleway, HashiCorp Vault, AWS Secrets Manager, or any of the other providers. It is fetched on first use and cached per key version.

Program.cs
// The bootstrap provider holds the key. It is built first and owned by the caller.
await using var keyProvider = new SecretProviderBuilder()
.WithAzureKeyVault(options => options.VaultUri = "https://my-vault.vault.azure.net/")
.Build();

await using var provider = new SecretProviderBuilder()
.WithPostgreSql(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=app;Password=secret";
options.EncryptionKeyProvider = new SecretProviderKeySource(keyProvider, "sm-data-key");
})
.Build();

Store the key as base64 — openssl rand -base64 32 — and rotate it by putting a new version of that secret. The current key is cached per process: new writes pick up the new version on restart, or immediately if you call InvalidateKey(). Existing rows stay readable either way, because each row records the key version it was written with and GetKeyAsync fetches that exact version on demand.

The same pattern works with Scaleway:

await using var keyProvider = new SecretProviderBuilder()
.WithScaleway(options =>
{
options.Region = "fr-par";
options.ProjectId = "...";
options.AccessKey = "...";
options.SecretKey = "...";
})
.Build();

await using var provider = new SecretProviderBuilder()
.WithPostgreSql(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=app;Password=secret";
options.EncryptionKeyProvider = new SecretProviderKeySource(keyProvider, "sm/data-key");
})
.Build();
warning

Build the key provider first, and never point a key source at the store it encrypts. A self-referential key source recurses or fails on first use — the fetch happens inside the operation that needs the key, so it cannot bootstrap itself.

SecretProviderKeySource does not dispose the provider it wraps. The caller owns the bootstrap provider and may use it for other keys.

tip

Call WarmUpAsync() to resolve the key and ensure the schema at startup rather than on the first read or write. Without it, a misconfigured key source surfaces only when the first secret is accessed.

Environment Variables​

Terminal
export SECRETS_PROVIDER=postgresql
export SECRETS_POSTGRESQL_CONNECTION_STRING="Host=localhost;Database=secrets;Username=app;Password=secret"
export SECRETS_POSTGRESQL_SCHEMA=public
export SECRETS_POSTGRESQL_TABLE_PREFIX=sm_
export SECRETS_POSTGRESQL_AUTO_CREATE_SCHEMA=true
export SECRETS_POSTGRESQL_ENCRYPTION_KEY=base64encodedkey

YAML Configuration​

secretstore.yaml
provider:
postgresql:
connection:
string: "Host=localhost;Database=secrets;Username=app;Password=secret"
schema: public
table:
prefix: sm_
auto:
create:
schema: true
encryption:
key: base64encodedkey # or keyFile: /path/to/keyfile

Dependency Injection (IOptions<T>)​

Bind the same settings from appsettings.json through the DI container:

var provider = new SecretProviderBuilder()
.ConfigurePostgreSql(configuration)
.Build();
{
"SecretsManager": {
"PostgreSql": {
"ConnectionString": "Host=localhost;Database=secrets;Username=app;Password=pass"
}
}
}

This reads from the SecretsManager:PostgreSql section and validates all options.

Audit Sink​

The same package also ships a PostgreSQL-backed audit sink, so secret access records can live in the database you already operate:

Program.cs
var provider = new SecretProviderBuilder()
.WithPostgreSql(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=app;Password=secret";
})
.WithPostgreSqlAuditSink(options =>
{
options.ConnectionString = "Host=localhost;Database=secrets;Username=audit_app;Password=secret";
options.Schema = "public";
options.TableName = "sm_secret_audit_log";
})
.Build();
OptionTypeRequiredDefaultDescription
ConnectionStringstringYes""PostgreSQL connection string
SchemastringNo"public"Schema for the audit table
TableNamestringNo"sm_secret_audit_log"Audit table name (48 characters or fewer, so the generated index name fits)
AutoCreateSchemaboolNofalseCreate the table and timestamp index on first write. Requires CREATE on the schema

The sink writes one row per audited operation. It stores the operation, provider, key, version, outcome, timestamp, duration, actor, correlation id, trace id, and failure type. It never stores a secret value. See Audit Trail for the table schema, failure semantics, and actor propagation.

Transport security​

Npgsql defaults to SslMode=Prefer, which permits an unencrypted connection and does not verify the server certificate. Add SSL Mode=VerifyFull to the connection string in any environment that is not a trusted local socket:

Host=db.example.com;Database=secrets;Username=app;Password=...;SSL Mode=VerifyFull

Without it, the database password, secret keys, tags and any unencrypted values can be read or modified in transit.

Least-privilege setup​

AutoCreateSchema defaults to false so the runtime account does not need CREATE on the schema. Create the tables from a migration role and grant the application only DML rights on the two tables:

CREATE SCHEMA secrets AUTHORIZATION secrets_owner;
REVOKE ALL ON SCHEMA secrets FROM PUBLIC;
GRANT USAGE ON SCHEMA secrets TO secrets_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON secrets.sm_secrets, secrets.sm_secret_versions TO secrets_app;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA secrets TO secrets_app;
options.AutoCreateSchema = false;          // the migration role creates the tables
options.SchemaOwner = "secrets_owner"; // the app connects as secrets_app

When AutoCreateSchema is true, the provider checks pg_class.relowner for both tables after the DDL and throws if the owner is not SchemaOwner (or the connected role when it is unset). A role that can create objects in the target schema could otherwise create the tables first, own them, and read every secret written afterwards — or add a trigger that copies values elsewhere.

Encryption​

When EncryptionKey or EncryptionKeyFile is configured, secret values are encrypted before storage using AES-256-GCM.

Encrypted values are stored as JSON in the value column:

{"n":"<nonce-base64>","c":"<ciphertext-base64>","t":"<tag-base64>"}

Each version row has an encrypted boolean column. The provider refuses to mix the two states: a row marked unencrypted while a key is configured, or an encrypted row with no key configured, both throw SecretProviderException. This closes the downgrade attack — UPDATE ... SET encrypted = false, value = 'attacker-value', which anyone with database access could otherwise use to forge a secret such as a connection string pointing at their own server. It also means a key cannot simply be added to a database that already holds plaintext rows; those rows must be rewritten.

tip

Generate a 32-byte encryption key with:

openssl rand -base64 32

Database Schema​

The provider creates two tables (auto-created when AutoCreateSchema is true):

Secrets Table (sm_secrets)​

ColumnTypeDescription
idSERIALPrimary key
keyTEXT UNIQUESecret identifier
content_typeTEXTMIME type (nullable)
tagsJSONBKey-value metadata (nullable)
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp

Versions Table (sm_secret_versions)​

ColumnTypeDescription
idSERIALPrimary key
secret_idINTEGERFK to secrets table (cascade delete)
versionINTEGERAuto-incrementing version per secret
valueTEXTSecret value (encrypted or plaintext)
encryptedBOOLEANWhether the value is encrypted
created_atTIMESTAMPTZCreation timestamp
is_currentBOOLEANWhether this is the latest version

Usage​

// Standard ISecretProvider operations work identically
var secret = await provider.GetSecretAsync("my-secret");
await provider.PutSecretAsync("new-secret", "secret-value");
var versions = await provider.GetSecretVersionsAsync("my-secret");
await provider.DeleteSecretAsync("old-secret");
Connection Pooling

The provider uses NpgsqlDataSource internally, which manages connection pooling automatically. You do not need to configure pooling separately.