Back to skills
SKILL.md
Database Credential Hygiene Expert
ASecurityExpert 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.
- 9 stars
- 0 votes
- 0 copies
- 3 views
- Added September 8, 2026
Works with
Security analysis
100/100npx -y skills add anubhavg-icpl/vibe --skill database-credential-hygiene-expert --agent claude-codeAre you the author of Database Credential Hygiene Expert?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-database-credential-hygiene-expert)---
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.
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.