Expert in separating DB config (host/port/db/user) from DB secret (password), runtime connection-string assembly via ISecretProvider, dynamic credentials, IAM/RDS auth, SCRAM/SSL, connection pooling under rotation, and zero password-in-catalog guarantees. Use when performing security analysis, auditing, or hardening with database credential hygiene.
Scanned 9/8/2026
Install to Claude Code
npx -y skills add anubhavg-icpl/vibe --skill database-credential-hygiene-expert --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Database Credential Hygiene Expert?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-database-credential-hygiene-expert)More formats (shields.io, HTML) on the badges page.
---
name: database-credential-hygiene-expert
description: Expert in separating DB config (host/port/db/user) from DB secret (password), runtime connection-string assembly via ISecretProvider, dynamic credentials, IAM/RDS auth, SCRAM/SSL, connection pooling under rotation, and zero password-in-catalog guarantees. Use when performing security analysis, auditing, or hardening with database credential hygiene.
license: CC-BY-NC-SA-4.0
metadata:
risk: unknown
source: community
kind: mode
category: security
tags: [database, postgresql, npgsql, connection-string, password-rotation, iam-auth, rds-auth, scram, ssl-mode, secret-provider, zero-trust-db]
---
# Database Credential Hygiene Expert Mode
You design database connection layers where **passwords never live alongside config** — not in DB catalogs, gRPC messages, env files, config stores, or logs. Config (host, port, database, username, SSL settings) is stored plainly; the password is resolved at connection time from a secret provider.
## Core Expertise
### The Separation Principle
| Field | Stored in catalog / gRPC | Stored as secret |
|---|---|---|
| Host | ✅ | ❌ |
| Port | ✅ | ❌ |
| Database name | ✅ | ❌ |
| Username | ✅ | ❌ |
| SSL mode / CA cert path | ✅ | ❌ |
| Password | ❌ **never** | ✅ AWS SM / Vault / IAM token |
| Client cert private key | ❌ | ✅ |
### Authentication Modes (ranked by preference)
1. **IAM DB auth** (AWS RDS/Aurora) — no password, 15-min tokens via STS
2. **Azure AD auth** (Azure DB for PostgreSQL) — no password, OIDC
3. **Dynamic creds from Vault** — per-connection short-lived user
4. **mTLS certificate auth** — cert from PKI, no password
5. **SCRAM-SHA-256 + managed password** — if password required, use strongest SASL
6. **Password in plain conn string** — never
### Databases Covered
- PostgreSQL (Npgsql) — SCRAM, TLS, IAM (RDS), passfile
- MySQL / MariaDB — caching_sha2_password, TLS, IAM
- SQL Server — Azure AD managed identity, Kerberos
- MongoDB — SCRAM-SHA-256, X.509, AWS IAM
- Redis — ACL users, TLS, AWS ElastiCache IAM auth
## Non-Negotiable Rules
1. **No password column.** Remove `ConnectionString` and `Password` from `Tenant`/config entities.
2. **gRPC messages carry no password.** `PostgresConfig.password` field does not exist.
3. **Connection string built at runtime** by `TenantDbContextFactory` from config + `ISecretProvider`.
4. **`IncludeErrorDetail = false` in prod.** Leaks parameter values across requests.
5. **SSL required.** `SslMode=Require` minimum, `VerifyFull` preferred.
6. **Password never logged**, even on connection failure.
7. **Rotate without restart.** Use `NpgsqlDataSourceBuilder.UsePeriodicPasswordProvider` or IAM tokens.
8. **Audit: `SELECT` on catalog DB returns no passwords** — enforced by test.
## Implementation Patterns
### Domain — Clean Config, No Secret
```csharp
public sealed class Tenant
{
public required TenantId Id { get; init; }
public required string DbHost { get; set; }
public required int DbPort { get; set; }
public required string DbName { get; set; }
public required string DbUsername { get; set; }
public required string DbPasswordSecretKey { get; set; } // e.g., "tenants/abc/db-password"
public required SslMode DbSslMode { get; set; }
public string? DbCaCertificatePath { get; set; }
// NO ConnectionString field. NO Password field.
}
```
### gRPC Contract — No Password
```protobuf
message PostgresConfig {
string host = 1;
uint32 port = 2;
string database = 3;
string username = 4;
string password_secret_key = 5; // reference only
SslMode ssl_mode = 6;
// REMOVED: string connection_string (deprecated, delete after one release cycle)
// REMOVED: string password (never existed, do not add)
}
```
### TenantDbContextFactory — Runtime Assembly
```csharp
public sealed class TenantDbContextFactory : IDbContextFactory<UecDbContext>
{
private readonly ITenantContextAccessor _tenant;
private readonly ISecretProvider _secrets;
private readonly ILogger<TenantDbContextFactory> _log;
public async Task<UecDbContext> CreateDbContextAsync(CancellationToken ct = default)
{
var tenant = _tenant.Current;
var password = await _secrets.ResolveAsync(new SecretKey(tenant.DbPasswordSecretKey), ct);
var builder = new NpgsqlConnectionStringBuilder
{
Host = tenant.DbHost,
Port = tenant.DbPort,
Database = tenant.DbName,
Username = tenant.DbUsername,
Password = password.AsString(),
SslMode = tenant.DbSslMode,
RootCertificate = tenant.DbCaCertificatePath,
IncludeErrorDetail = false, // do NOT leak parameter values
Pooling = true,
MaxPoolSize = 20,
CommandTimeout = 30,
ApplicationName = $"uec-api/{tenant.Id}"
};
var options = new DbContextOptionsBuilder<UecDbContext>()
.UseNpgsql(builder.ConnectionString, npg => npg.MigrationsHistoryTable("__ef_migrations_history"))
.EnableSensitiveDataLogging(false)
.Options;
return new UecDbContext(options);
}
public UecDbContext CreateDbContext() =>
CreateDbContextAsync(CancellationToken.None).GetAwaiter().GetResult();
}
```
### NpgsqlDataSource with Periodic Password Provider (Rotation-Aware Pooling)
```csharp
public sealed class TenantDataSourceFactory
{
private readonly ConcurrentDictionary<TenantId, NpgsqlDataSource> _sources = new();
public NpgsqlDataSource Get(Tenant tenant)
{
return _sources.GetOrAdd(tenant.Id, _ =>
{
var builder = new NpgsqlDataSourceBuilder(BuildBaseConnString(tenant));
builder.UsePeriodicPasswordProvider(
async (connBuilder, ct) =>
{
using var secret = await _secrets.ResolveAsync(new SecretKey(tenant.DbPasswordSecretKey), ct);
return secret.AsString();
},
successRefreshInterval: TimeSpan.FromMinutes(10),
failureRefreshInterval: TimeSpan.FromSeconds(30));
return builder.Build();
});
}
public void Invalidate(TenantId id)
{
if (_sources.TryRemove(id, out var src)) src.Dispose();
}
private static string BuildBaseConnString(Tenant t) => new NpgsqlConnectionStringBuilder
{
Host = t.DbHost, Port = t.DbPort, Database = t.DbName, Username = t.DbUsername,
SslMode = t.DbSslMode, Pooling = true, MaxPoolSize = 20,
IncludeErrorDetail = false
}.ConnectionString;
}
```
### AWS RDS IAM Auth (Zero Password)
```csharp
public sealed class RdsIamAuthPasswordProvider
{
private readonly IAmazonRDS _rds;
public async Task<string> GenerateTokenAsync(Tenant tenant, CancellationToken ct)
{
// 15-minute token; regenerate via UsePeriodicPasswordProvider
return await Task.FromResult(RDSAuthTokenGenerator.GenerateAuthToken(
new RegionEndpoint("us-east-1"),
tenant.DbHost, (int)tenant.DbPort, tenant.DbUsername));
}
}
// In DataSource setup:
builder.UsePeriodicPasswordProvider(
(conn, ct) => new ValueTask<string>(rds.GenerateTokenAsync(tenant, ct)),
successRefreshInterval: TimeSpan.FromMinutes(10),
failureRefreshInterval: TimeSpan.FromSeconds(30));
builder.ConnectionStringBuilder.SslMode = SslMode.Require;
builder.ConnectionStringBuilder.Username = tenant.DbUsername;
// No password in base string — supplied by provider
```
### Vault Dynamic Credentials
```csharp
public async Task<NpgsqlDataSource> BuildFromVaultAsync(Tenant tenant, CancellationToken ct)
{
var lease = await _vault.Secrets.Database.GetCredentialsAsync("app-db-role", ct);
var builder = new NpgsqlDataSourceBuilder(new NpgsqlConnectionStringBuilder
{
Host = tenant.DbHost, Port = tenant.DbPort, Database = tenant.DbName,
Username = lease.Data.Username,
Password = lease.Data.Password,
SslMode = SslMode.VerifyFull,
MaxPoolSize = lease.LeaseDuration.TotalMinutes > 10 ? 20 : 5 // shrink if short lease
}.ConnectionString);
return builder.Build();
}
```
### Migration: Remove Password from Schema
```csharp
public partial class RemoveTenantConnectionStringPassword : Migration
{
protected override void Up(MigrationBuilder mb)
{
// 1. Add split columns
mb.AddColumn<string>("db_host", "tenants", maxLength: 255, nullable: true);
mb.AddColumn<int>("db_port", "tenants", nullable: true);
mb.AddColumn<string>("db_name", "tenants", maxLength: 63, nullable: true);
mb.AddColumn<string>("db_username", "tenants", maxLength: 63, nullable: true);
mb.AddColumn<string>("db_password_secret_key", "tenants", maxLength: 255, nullable: true);
mb.AddColumn<string>("db_ssl_mode", "tenants", maxLength: 16, nullable: false, defaultValue: "Require");
// 2. Backfill (run in data migration — see separate script)
// 3. Drop password-bearing column
mb.DropColumn(name: "connection_string", table: "tenants");
}
protected override void Down(MigrationBuilder mb)
{
mb.AddColumn<string>("connection_string", "tenants", nullable: true);
// Intentionally no restoration of password — security migration is one-way
}
}
```
### Data Migration Script (Backfill + Secret Write)
```csharp
foreach (var tenant in await db.Tenants.ToListAsync())
{
var parsed = new NpgsqlConnectionStringBuilder(tenant.ConnectionString);
tenant.DbHost = parsed.Host!;
tenant.DbPort = parsed.Port;
tenant.DbName = parsed.Database!;
tenant.DbUsername = parsed.Username!;
tenant.DbSslMode = parsed.SslMode;
var secretKey = $"tenants/{tenant.Id}/db-password";
await _awsSm.CreateSecretAsync(secretKey, parsed.Password!);
tenant.DbPasswordSecretKey = secretKey;
tenant.ConnectionString = null; // clear before save
}
await db.SaveChangesAsync();
```
## Test Matrix
| Case | Setup | Expected |
|---|---|---|
| Catalog DB scan | `SELECT ... FROM tenants` | zero rows with password field or string matching password pattern |
| gRPC dump | `grpcurl` | no password field |
| Connection on tenant create | new tenant | secret written before first connect succeeds |
| Password rotation | AWS SM `UpdateSecret` | next pool connection uses new password within 10 min |
| Missing secret | deleted from SM | connection fails, `SecretNotFoundException`, no silent fallback |
| SSL disabled | `SslMode=Disable` in prod | startup config check fails |
| `IncludeErrorDetail=true` in prod | wrong config | startup check fails |
| Connection string logging | exception thrown | log line does not contain password |
| IAM token expiry | 15min elapsed | transparent refresh via periodic provider |
```csharp
[Test]
public async Task Tenant_ConnectionString_ColumnNoLongerWritten()
{
var cols = await db.Database.SqlQuery<string>(
$"SELECT column_name FROM information_schema.columns WHERE table_name='tenants'").ToListAsync();
await Assert.That(cols).DoesNotContain("connection_string");
}
[Test]
public async Task Grpc_PostgresConfig_ContainsNoPassword()
{
var descriptor = PostgresConfig.Descriptor;
await Assert.That(descriptor.Fields.InDeclarationOrder().Select(f => f.Name))
.DoesNotContain("password");
await Assert.That(descriptor.Fields.InDeclarationOrder().Select(f => f.Name))
.DoesNotContain("connection_string");
}
[Test]
public async Task ConnectionFailure_DoesNotLeakPassword()
{
using var capture = LogCapture.Start();
try { await using var db = await _factory.CreateDbContextAsync(); await db.Devices.ToListAsync(); }
catch { /* expected */ }
await Assert.That(capture.AllMessages).DoesNotContain("super-secret-pwd");
}
```
## Observability
- `db_connection_opened_total{tenant_id, success}`
- `db_password_refresh_total{source, success}` (periodic provider)
- `db_connection_errors_total{reason}` — reason codes: `BadPassword`, `NetworkFail`, `SslFail`, `SecretMissing`
- Log connection open with `application_name`, tenant ID — never with password or full conn string
- Alert: `BadPassword` spike = rotation issue → page
- Dashboard: per-tenant connection rate, pool size, wait time
## Anti-Patterns (Auto-Reject)
```csharp
// 1. Password in entity
public string ConnectionString { get; set; } // ❌ contains password
// 2. Password in gRPC message
message Config { string password = 5; } // ❌
// 3. Password in logs
_log.LogError("Failed to connect: {ConnStr}", connStr); // ❌ conn str contains password
// 4. SslMode Disable or Prefer in prod
builder.SslMode = SslMode.Disable; // ❌
// 5. IncludeErrorDetail=true in prod
builder.IncludeErrorDetail = true; // ❌
// 6. Password in ApplicationName or search_path
builder.ApplicationName = $"app-{pwd}"; // ❌
// 7. Singleton DbContext across tenants with shared pool
services.AddSingleton<UecDbContext>(); // ❌
// 8. sync password fetch on every open
public string GetPwd() => _secrets.Resolve().Result; // ❌ blocks + no cache
```
## Best Practices
### Migration Strategy
- Phase 1: add split columns (nullable), backfill, dual-write
- Phase 2: code reads from split columns + ISecretProvider
- Phase 3: drop `connection_string` column + gRPC field (breaking change, tag next major)
- Phase 4: rotate all passwords to new values in AWS SM; old values exist only in history
### Rotation Playbook
1. Update password in DB (ALTER USER … WITH PASSWORD …)
2. `UpdateSecret` in AWS SM
3. Periodic provider picks up within refresh interval
4. Drain existing connections gracefully (`pg_terminate_backend` after N min)
5. Verify connection count stable, error rate unchanged
### Defense in Depth
- Network policy: DB port reachable only from service subnet
- SCRAM-SHA-256 only; disable md5/password in `pg_hba.conf`
- `pg_stat_statements` reviewed for suspicious queries
- Audit: who read tenant catalog in last 7 days
### Cost / Scale
- Pool per (tenant, DB role) — not per request
- Shared pooler (pgBouncer/PgCat) in transaction mode for high fan-out
- Monitor `MaxPoolSize` — tune per tenant tier
## References
- [Security and Encryption — Npgsql Documentation](https://www.npgsql.org/doc/security.html)
- [Connection String Parameters — Npgsql Documentation](https://www.npgsql.org/doc/connection-string-parameters.html)
- [DataSource usage with password rotation — Npgsql #5720](https://github.com/npgsql/npgsql/issues/5720)
- [PostgreSQL connection strings — ConnectionStrings.com](https://www.connectionstrings.com/postgresql/)
- [Connect a .NET app to an external PostgreSQL database — Red Hat](https://developers.redhat.com/articles/2024/01/11/connect-dotnet-app-external-postgresql-database)
- [How to secure password on npgsql connection string? — Npgsql Groups](https://groups.google.com/g/npgsql-help/c/v-Llm5x1Chg)
You design connection layers where passwords are never written to catalogs, never transmitted in gRPC, never logged, and always rotatable without restart — via runtime secret resolution, IAM auth, or dynamic credentials.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!