Skip to content

Secrets Management Patterns for Cloud-Native Applications

Robust secrets management with HashiCorp Vault, AWS Secrets Manager and sealed secrets: rotation, injection, least privilege and no secret sprawl.

5 min read
Secret lifecycle diagram showing creation, storage in a vault, injection into running services, automatic rotation, and audit logging of access events

Secrets sprawl is the silent security crisis in most organizations. API keys in environment variables, database passwords in config files committed to repos, tokens shared in Slack messages—each one is an attack surface waiting to be exploited. Modern secrets management isn't just about encrypting values. It's about controlling access, enabling rotation, maintaining audit trails, and keeping secrets out of places they don't belong.

The patterns here cover the full lifecycle: how secrets are created, stored, delivered to applications, rotated, and revoked—without introducing so much operational complexity that teams bypass the system entirely.

The Secrets Anti-Pattern Catalog

Before implementing solutions, recognize the patterns that create exposure.

ymlyaml
# ❌ Secrets embedded in configuration
# docker-compose.yml
services:
  api:
    environment:
      DB_PASSWORD: "super_secret_password_123"
      API_KEY: "sk-live-abc123def456"
      JWT_SECRET: "my-jwt-signing-key"
# Visible in process listings, docker inspect,
# version control history, CI/CD logs
ymlyaml
# ✅ Secrets referenced, not embedded
# docker-compose.yml
services:
  api:
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      API_KEY_FILE: /run/secrets/api_key
    secrets:
      - db_password
      - api_key
 
secrets:
  db_password:
    external: true
  api_key:
    external: true
# Secrets injected at runtime, never in config files

The file-based injection pattern (_FILE suffix) is supported by many Docker images natively. The application reads the secret from a file at startup rather than from an environment variable, preventing exposure in process listings and crash dumps.

HashiCorp Vault Integration

Vault provides dynamic secrets, automatic rotation, and fine-grained access policies. The key architectural pattern is using short-lived tokens with narrow permissions.

tstypescript
// Vault client with token renewal and secret lease management
import Vault from "node-vault";
 
interface VaultConfig {
  endpoint: string;
  roleId: string;
  secretId: string;
}
 
class SecretManager {
  private client: ReturnType<typeof Vault>;
  private leases: Map<string, { leaseId: string; ttl: number }> =
    new Map();
 
  constructor(private config: VaultConfig) {
    this.client = Vault({
      apiVersion: "v1",
      endpoint: config.endpoint,
    });
  }
 
  async authenticate(): Promise<void> {
    const result = await this.client.approleLogin({
      role_id: this.config.roleId,
      secret_id: this.config.secretId,
    });
    this.client.token = result.auth.client_token;
 
    // Schedule token renewal before expiry
    const renewalInterval =
      (result.auth.lease_duration * 0.75) * 1000;
    setInterval(() => this.renewToken(), renewalInterval);
  }
 
  private async renewToken(): Promise<void> {
    try {
      await this.client.tokenRenewSelf();
    } catch {
      // Token renewal failed — re-authenticate
      await this.authenticate();
    }
  }
 
  async getDatabaseCredentials(
    role: string
  ): Promise<{ username: string; password: string }> {
    const result = await this.client.read(
      `database/creds/${role}`
    );
 
    // Track lease for renewal
    this.leases.set(role, {
      leaseId: result.lease_id,
      ttl: result.lease_duration,
    });
 
    return {
      username: result.data.username,
      password: result.data.password,
    };
  }
 
  async getSecret(path: string): Promise<Record<string, string>> {
    const result = await this.client.read(
      `secret/data/${path}`
    );
    return result.data.data;
  }
 
  async revokeAllLeases(): Promise<void> {
    for (const [, lease] of this.leases) {
      await this.client.write("sys/leases/revoke", {
        lease_id: lease.leaseId,
      });
    }
    this.leases.clear();
  }
}
 
// Usage with automatic credential rotation
const vault = new SecretManager({
  endpoint: process.env.VAULT_ADDR ?? "http://vault:8200",
  roleId: process.env.VAULT_ROLE_ID ?? "",
  secretId: process.env.VAULT_SECRET_ID ?? "",
});
 
await vault.authenticate();
 
// Dynamic database credentials — unique per instance,
// automatically expired
const dbCreds = await vault.getDatabaseCredentials(
  "api-readonly"
);

Dynamic database credentials are Vault's killer feature. Each application instance gets its own username and password with a configurable TTL. When the lease expires, Vault revokes the credentials at the database level. A compromised credential is only valid for hours, not forever.

Kubernetes Sealed Secrets

For teams that want secrets in Git without the operational overhead of Vault, Sealed Secrets encrypt values that only the cluster can decrypt.

ymlyaml
# ❌ Kubernetes Secret — base64 encoded, not encrypted
apiVersion: v1
kind: Secret
metadata:
  name: api-credentials
data:
  api-key: c2stbGl2ZS1hYmMxMjNkZWY0NTY=
# base64 is NOT encryption. Anyone with repo access
# can decode this.
ymlyaml
# ✅ Sealed Secret — encrypted, safe to commit
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: api-credentials
  namespace: production
spec:
  encryptedData:
    api-key: AgBy3i4OJSWK+PiTySYZZA9r...
    # Encrypted with the cluster's public key
    # Only the sealed-secrets controller can decrypt
  template:
    metadata:
      name: api-credentials
    type: Opaque
shbash
# Workflow: encrypt locally, commit safely
# Fetch the cluster's public cert
kubeseal --fetch-cert \
  --controller-namespace kube-system \
  > pub-cert.pem
 
# Encrypt a secret value
echo -n "sk-live-abc123" | kubeseal \
  --raw \
  --from-file=/dev/stdin \
  --namespace production \
  --name api-credentials \
  --cert pub-cert.pem
 
# The output is safe to commit to Git

Application-Level Secret Injection Pattern

Regardless of which backend you use, the application should follow a consistent pattern for receiving secrets.

tstypescript
// ❌ Scattered secret access throughout codebase
async function handleRequest(req: Request) {
  const apiKey = process.env.STRIPE_API_KEY; // Where does this come from?
  const dbUrl = process.env.DATABASE_URL;     // Who rotates this?
  // Secrets scattered across handler files
}
tstypescript
// ✅ Centralized secret provider with lazy loading
interface SecretProvider {
  get(key: string): Promise<string>;
  getAll(keys: string[]): Promise<Map<string, string>>;
}
 
class CompositeSecretProvider implements SecretProvider {
  constructor(
    private providers: SecretProvider[],
  ) {}
 
  async get(key: string): Promise<string> {
    for (const provider of this.providers) {
      try {
        return await provider.get(key);
      } catch {
        continue;
      }
    }
    throw new Error(`Secret not found: ${key}`);
  }
 
  async getAll(
    keys: string[]
  ): Promise<Map<string, string>> {
    const results = new Map<string, string>();
    for (const key of keys) {
      results.set(key, await this.get(key));
    }
    return results;
  }
}
 
// File-based provider for Docker/Kubernetes secrets
class FileSecretProvider implements SecretProvider {
  constructor(private basePath: string = "/run/secrets") {}
 
  async get(key: string): Promise<string> {
    const filePath = `${this.basePath}/${key}`;
    const content = await fs.readFile(filePath, "utf-8");
    return content.trim();
  }
 
  async getAll(
    keys: string[]
  ): Promise<Map<string, string>> {
    const results = new Map<string, string>();
    for (const key of keys) {
      results.set(key, await this.get(key));
    }
    return results;
  }
}
 
// Environment variable provider as fallback
class EnvSecretProvider implements SecretProvider {
  async get(key: string): Promise<string> {
    const value = process.env[key];
    if (!value) {
      throw new Error(`Environment variable ${key} not set`);
    }
    return value;
  }
 
  async getAll(
    keys: string[]
  ): Promise<Map<string, string>> {
    const results = new Map<string, string>();
    for (const key of keys) {
      results.set(key, await this.get(key));
    }
    return results;
  }
}
 
// Application bootstrap: resolve all secrets once at startup
const secrets = new CompositeSecretProvider([
  new FileSecretProvider(),
  new EnvSecretProvider(),
]);
 
const config = {
  stripeKey: await secrets.get("STRIPE_API_KEY"),
  databaseUrl: await secrets.get("DATABASE_URL"),
  jwtSecret: await secrets.get("JWT_SECRET"),
};
// Pass config to services — no secret lookups in handlers

Secret Rotation Without Downtime

The hardest part of secrets management is rotation—changing a secret without disrupting running services.

tstypescript
// Dual-read pattern for zero-downtime rotation
interface RotatableSecret {
  current: string;
  previous: string | null;
  rotatedAt: Date;
}
 
class RotatingApiKeyValidator {
  private keys: RotatableSecret;
 
  constructor(keys: RotatableSecret) {
    this.keys = keys;
  }
 
  validate(providedKey: string): boolean {
    // Accept both current and previous during rotation window
    if (providedKey === this.keys.current) return true;
    if (
      this.keys.previous &&
      providedKey === this.keys.previous
    ) {
      // Log that a client is still using the old key
      console.warn(
        "Request using previous API key — client needs update"
      );
      return true;
    }
    return false;
  }
}
 
// Rotation procedure:
// 1. Generate new secret, store as "current"
// 2. Move old "current" to "previous"
// 3. Deploy — services accept both keys
// 4. Update all clients to use new key
// 5. After grace period, remove "previous"

Key Takeaways

Secrets embedded in environment variables, config files, or source code create attack surfaces through process listings, container inspection, crash dumps, and version control history—use file-based injection or vault references instead. Dynamic secrets from HashiCorp Vault create unique credentials per application instance with automatic expiration, limiting the blast radius of a compromise to the credential's TTL. Sealed Secrets let you store encrypted secrets in Git safely—only the Kubernetes cluster's sealed-secrets controller can decrypt them, enabling GitOps workflows without exposing values. A centralized SecretProvider abstraction decouples your application from the secret storage backend, making it possible to use file-based secrets in production and environment variables in development without code changes. Zero-downtime rotation uses a dual-read pattern: accept both current and previous secret values during a grace period, giving clients time to update while maintaining service continuity.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX