Education › Security › Stage 2: Securing systems

Secrets management

Where secrets leak, vaults and KMS, short-lived credentials, rotation, and keeping keys out of git.

Intermediate ~30 min read Module 6 of 16

A secret is any value whose disclosure lets someone act as you: an API key, a database password, a signing key, a session token. Secrets leak in boring ways — a commit, a log line, a screenshot, an environment dump in an error page — and the leak is usually discovered by an attacker first. This module is about the whole lifecycle: keeping secrets out of the places they leak from, storing and delivering them through a secrets manager, replacing long-lived secrets with short-lived ones wherever possible, rotating without downtime, and responding correctly when one gets out. The DevOps track introduced the tools; this module makes the practice systematic.

After this module you can
  • Identify every place a secret can leak and close the common ones (git, logs, images, tickets, CI output)
  • Deliver secrets to workloads from a secrets manager or KMS at runtime rather than baking them in
  • Replace static credentials with short-lived, identity-derived ones (cloud roles, OIDC, dynamic database credentials)
  • Rotate a secret without an outage using dual-validity
  • Run the response to a leaked secret in the right order

Where secrets leak

Secrets do not leak from vaults. They leak from the edges: a .env file added to git, a debug log that prints the request headers, a Docker layer that copied a key and "deleted" it in the next layer, a CI job that echoed its environment, a Slack message, a stack trace on an error page, a public object storage bucket with a config backup. Public repositories are scraped continuously; a cloud key pushed to GitHub is typically used within minutes.

Leak pathControl
Source code and git historyPre-commit and CI secret scanning; .gitignore for env files; history rewrite is not enough, rotate
Container imagesNever COPY secrets; multi-stage builds; use build secrets that do not persist in layers
Logs and error pagesRedact known secret shapes at the logger; generic error pages; never log headers or env
CI/CD outputMasked secrets, no env dumps, forked PRs get no secrets
Chat, tickets, wikisA policy plus a scanner; share via the secrets manager's link, not the value
Find secrets before they are pushed: gitleaks as a pre-commit hook and in CI, scanning history as well as the working tree.
bash
# one-off scan of the whole history
gitleaks git --redact -v .

# pre-commit: block commits that contain a secret
cat > .pre-commit-config.yaml <<'EOF'
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks
EOF
pre-commit install
Watch out

Removing a secret from a later commit changes nothing: the value is in the history and in every clone. A leaked secret is a compromised secret; the only fix is revocation and rotation.

A secrets manager, and how workloads get secrets

A secrets manager (a cloud provider's secrets service, HashiCorp Vault, or similar) stores secrets encrypted with a KMS-backed key, controls access with identity-based policy, logs every read, versions values so rotation can roll back, and can generate secrets on demand. The application authenticates to it with its workload identity (an instance role, a Kubernetes service account, an OIDC token) and fetches what it needs at startup or on demand. Nothing is stored in code, images or CI variables except the identity that is allowed to ask.

Fetch at runtime with the workload's own identity; cache in memory; never write to disk or log.
python
import json

import boto3

_client = boto3.client("secretsmanager")
_cache: dict[str, dict] = {}


def get_secret(name: str) -> dict:
    """Return the secret's JSON fields; the instance/pod role must allow secretsmanager:GetSecretValue on it."""
    if name not in _cache:
        resp = _client.get_secret_value(SecretId=name)
        _cache[name] = json.loads(resp["SecretString"])
    return _cache[name]


db = get_secret("prod/orders/db")
# db["username"], db["password"], db["host"] -> build the connection; do not print them

On Kubernetes the same pattern is a CSI secrets-store driver or an operator (External Secrets) that syncs from the manager into Secret objects, with the manager remaining the source of truth. Plain Kubernetes Secrets are base64, not encrypted, unless encryption at rest is configured for etcd; treat them as delivery, not storage. Mount secrets as files with restrictive permissions rather than environment variables where you can: environment variables are inherited by child processes and appear in crash dumps and debug endpoints.

Tip

Scope access by path and identity: the orders service can read prod/orders/* and nothing else. A single "app" role with read on everything turns one compromised pod into all your secrets.

Short-lived beats well-guarded

The best secret is one that expires before it can be misused. Cloud instance and pod roles hand out credentials that rotate automatically every few hours. OIDC federation lets CI jobs exchange a signed job token for cloud credentials that last minutes. Dynamic database credentials go further: the secrets manager creates a database user with a lease when the application asks, and drops it when the lease expires, so there is no shared password to leak at all. Signed URLs and tokens with exp claims do the same for object access and APIs.

Dynamic Postgres credentials from Vault: each request creates a fresh user that expires on its own.
bash
# configure once: how Vault connects, and a role that defines the SQL to create a user
vault write database/config/orders \
  plugin_name=postgresql-database-plugin \
  allowed_roles="orders-app" \
  connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/orders" \
  username="vault" password="REPLACED_AFTER_ROTATE_ROOT"

vault write database/roles/orders-app \
  db_name=orders \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
    GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" max_ttl="24h"

# the application (authenticated by its identity) asks for credentials
vault read database/creds/orders-app
# username  v-k8s-orders-app-3Hx...   password  A1b...   lease_duration 1h

Where a static secret is unavoidable (a third-party API key, a legacy system password), shorten its life administratively: rotate it on a schedule, restrict where it can be used from (IP allow-lists, scoped permissions), and monitor its use so that use from a new place is an alert.

Rotation without an outage

Rotation fails in production when it is treated as a single swap: the new value is written, every consumer that still holds the old one breaks. The safe pattern is dual validity: create the new credential while the old one still works, roll consumers over to the new one, verify nothing uses the old one, then revoke it. For signing keys the same idea is key identifiers: publish both keys, sign with the new, verify with either, retire the old after all tokens signed by it have expired.

  1. Create the new secret (new key, new database user, new API key) alongside the old.
  2. Store it as the new version in the secrets manager; consumers that fetch on start pick it up on their next restart, consumers with a refresh loop pick it up within minutes.
  3. Roll consumers deliberately (rolling restart) and watch error rates and auth failures.
  4. Check the audit log: no successful use of the old version for a full cycle.
  5. Revoke the old secret. Keep the old version in the manager, disabled, for forensics.
Note

Build consumers to handle rotation from the start: read secrets at startup, refresh on a timer or on an auth failure, and never cache a secret to disk. A service that needs a redeploy to pick up a new password will be the reason rotation is skipped.

When a secret leaks: the order of operations

Speed and order matter more than blame. Assume the secret has already been used. Revoke or rotate first, before investigating, because every minute it remains valid is a minute of attacker access. Then audit: search cloud and application logs for use of the credential since the earliest possible exposure, looking especially for use from unfamiliar sources. Contain what the audit shows: new keys created, resources touched, data read. Fix the leak path so it cannot recur (add the scanner, redact the logger). Finally write it up blamelessly; the person who leaked it and reported it fast did the right thing, and the process that let it happen is what needs to change.

Auditing use of a leaked cloud key: who used it, from where, and what they did.
bash
# events by this access key in the last 24 hours (CloudTrail must be enabled)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIAEXAMPLEKEY \
  --start-time "$(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ)" \
  --query 'Events[].{time:EventTime,name:EventName,src:CloudTrailEvent}' --output json \
  | jq -r '.[] | "\(.time) \(.name) \(.src | fromjson | .sourceIPAddress)"' | sort | uniq -c | sort -rn | head

Prepare the playbook before you need it: who can revoke which secret, where the audit logs are, which consumers must be restarted, and who is told. A leak handled in twenty minutes with a clear playbook is an incident; the same leak discovered a month later from a cloud bill is a breach.

Hands-on practice

Scan, store, rotate, respond

  1. Run gitleaks git against a repository you own. If it finds anything real, rotate that secret now; then install the pre-commit hook and confirm a commit containing AKIA followed by sixteen uppercase characters is blocked.
  2. Store a test secret in a secrets manager (cloud, or a local Vault dev server started with vault server -dev). Write a fifteen-line script that reads it at startup with the workload's identity and prints only the field names, never the values.
  3. Create a second version of the secret. Modify the script to refresh on a 60-second timer and confirm it picks up the new version without a restart.
  4. Implement dual validity for an HMAC signing key: store two keys with ids, sign with the newest, verify against both, then remove the old one and confirm old signatures fail.
  5. Write the leak playbook for one real secret in your environment: revoke command, audit query, consumers to restart, who to inform. Keep it with the runbooks.
  6. Grep your application logs for anything shaped like a token or key (Bearer , AKIA, -----BEGIN). Add redaction at the logger for the shapes you find.
Cheat sheet

Secrets management — at a glance

Main things to focus on

  • Secrets leak at the edges: git, images, logs, CI output, chat; scan and redact there
  • Store in a secrets manager; workloads fetch at runtime with their own identity; access scoped by path
  • Prefer short-lived, identity-derived credentials (roles, OIDC, dynamic DB users) over static ones
  • Rotate with dual validity: create, roll, verify, revoke; consumers refresh without redeploys
  • Leak response: revoke, audit, contain, fix the path, write it up — in that order
  • Kubernetes Secrets are delivery, not storage; mount as files, encrypt etcd

Finding and preventing leaks

gitleaks git --redact -v .Scan full git history for secrets
pre-commit hook: gitleaksBlock commits containing secrets
.gitignore: .env, *.pem, *.tfvarsKeep local secret files out of git
docker build --secret id=npm,src=.npmrcBuild-time secret that leaves no layer
logger redaction: Bearer, AKIA, BEGIN PRIVATE KEYMask known shapes before writing
CI: secrets masked, none for forked PRsUntrusted code never sees secrets

Secrets manager patterns

get_secret_value(SecretId=...)Fetch at runtime; cache in memory only
policy: path prod/orders/* for role ordersScope reads by identity and path
versions: AWSCURRENT / AWSPREVIOUSRotation with rollback
External Secrets / CSI secrets-storeSync manager -> Kubernetes, manager stays source of truth
mount as file, mode 0400Prefer files over env vars for secrets
audit log every readUnusual reader or volume is a signal

Short-lived credentials

instance / pod roleAuto-rotating cloud credentials, nothing stored
OIDC: CI token -> cloud roleDeploys without long-lived keys
vault read database/creds/ROLEDynamic DB user with a lease
default_ttl=1h max_ttl=24hLeases expire; nothing to rotate manually
signed URL / token with expTime-boxed access to objects and APIs

Rotation and response

create -> roll consumers -> verify -> revokeDual validity avoids outages
kid in JWT header, verify with either keySigning key rotation
1. revoke 2. audit 3. contain 4. fix path 5. write upLeak response order
aws cloudtrail lookup-events --lookup-attributes AttributeKey=AccessKeyIdWho used a leaked key, from where
playbook per secret: revoke cmd, audit query, consumers, contactsPrepared before the leak

Common pitfalls

  • Deleting a leaked secret from git and calling it handled; the value is in the history and in every clone.
  • Baking secrets into container images or AMIs, where they persist in every layer and every copy.
  • One broad secrets-read role for all services, so one compromised pod reads everything.
  • Rotating by overwriting the only value, then discovering which consumers cached the old one from the outage.
  • Storing Kubernetes Secrets in git in plain base64, believing base64 is encryption.
  • Investigating a leak before revoking; the attacker keeps working while you read logs.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →