Stratify
Legal and compliance

Secrets & Key Management Procedure

docs/compliance/isms/procedures/PRO-11-secrets-key-management.md

Source updated 03. Aug. 2026

Secrets & Key Management Procedure

Formalises the practice already described in docs/secrets.md into a procedure. No secret, credential, or key is ever committed to the repository or placed in a .env file — this has been the rule since the project's first commit and is enforced in two places: local pre-commit hooks (gitleaks, detect-private-key) and the CI secret-scan job in .github/workflows/dos-ci.yml.

1. Where secrets live

  • Development: macOS Keychain, on the engineer's own machine, sourced into every shell session

via ~/.keychain-secrets.sh (itself sourced from ~/.zshrc).

  • Production: environment variables injected directly into the Hetzner Docker containers

running the stack. This is the current, real production mechanism — not Vercel. docs/secrets.md itself still has a stale line ("Vercel project envs in production") left over from before the 2026-06-03 Hetzner cutover (register/facts.md); this procedure states the correct mechanism and flags that line as a documentation gap to close (candidate for the governance/GOV-13 nonconformity log alongside the other stale-hosting-reference items).

2. Adding a new secret

  1. Dev: add it to Keychain —
   security add-generic-password -a "$USER" -s STRATIFY_<NAME> -w "<value>" -U
  1. Add the corresponding export line to ~/.keychain-secrets.sh:
   export <NAME>="$(security find-generic-password -a "$USER" -s STRATIFY_<NAME> -w 2>/dev/null)"

Re-source (source ~/.zshrc) to make it available in the current shell.

  1. Prod: add the variable to the Hetzner host's Docker environment configuration for the

relevant container(s) (compose override / systemd env file, per infra/hetzner/), and redeploy so the running container picks it up. There is no secrets-manager layer between the env file and the container — the file on the host is the source of truth for the running value.

  1. Add the variable name (not the value) to .env.example's "Secrets — DO NOT put here" comment

block, so the required-secret list stays discoverable without exposing anything.

3. Required secrets (from .env.example:34-51, cross-checked against docs/secrets.md)

VariablePurpose
STRATIFY_SUPABASE_SERVICE_ROLEServer-only Supabase service role — the de facto admin bypass for RLS (register/facts.md). Never exposed to the browser.
STRATIFY_STRIPE_SECRET_KEYServer-side Stripe SDK.
STRATIFY_STRIPE_WEBHOOK_SECRETVerifies inbound Stripe webhooks.
STRATIFY_SUPABASE_WEBHOOK_SECRETVerifies inbound Supabase auth-hook webhooks.
AWS_SES_ACCESS_KEY_ID / AWS_SES_SECRET_ACCESS_KEYAmazon SES credentials — the current transactional-email vendor, replaced Resend (apps/web/lib/email/ses.ts).
AWS_SES_SMTP_PASSWORDDerived SMTP password, used by GoTrue's mailer for OTP/recovery/invite codes.
STRATIFY_VAPID_PRIVATE_KEYWeb-push signing key.
STRATIFY_SENTRY_AUTH_TOKENSource-map upload, CI-only.
STRATIFY_DATABASE_URLDirect Postgres connection for migrations/scripts.
STRATIFY_AI_GATEWAY_API_KEYVercel AI Gateway → Anthropic, for signal-drafting assist.
STRATIFY_EXPO_ACCESS_TOKENExpo Push API auth.
STRATIFY_CRON_SECRETBearer token for /api/cron/* endpoints.
STRATIFY_PARTNER_WEBHOOK_SECRETHMAC key for outbound partner webhooks.

Note on drift: docs/secrets.md's own required-secrets table still lists STRATIFY_RESEND_API_KEY and describes Vercel as production — both stale, superseded by the AWS SES variables above and the Hetzner mechanism in §1. .env.example is the more current source; this procedure treats it as authoritative and flags docs/secrets.md for correction.

4. Rotation

docs/secrets.md's existing rotation table states the triggers:

TriggerAction
Engineer leavesRotate every secret they could have read. Service-role key first.
Partner-key suspected compromisedRevoke in partner_api_keys, issue replacement via /admin/partners.
Stripe webhook secret leakedRotate in Stripe Dashboard, re-add STRATIFY_STRIPE_WEBHOOK_SECRET.
Audit-chain divergenceTreat as P0; freeze service-role until forensics complete (docs/runbooks/db-recovery.md §3.3).

State plainly: this is documented policy, not an enforced or automated procedure. There is no secrets-expiry system, no reminder mechanism, and no scheduled rotation independent of a trigger firing. On a single-engineer team, none of the above triggers has ever fired for real — "engineer leaves" has never happened, no partner-key compromise or webhook-secret leak has occurred, and the one audit-chain-divergence-response path exists only as an unexercised runbook step. This procedure is written so that when a trigger does fire, the steps above are the first thing consulted rather than improvised.

5. Gaps, stated plainly

  • No key-management system exists beyond macOS Keychain (dev) and Docker environment variables

(prod) — no Vault, no cloud KMS, no centralized secret store with access logging.

  • No secret has an expiry date or a scheduled rotation independent of a trigger event.
  • No process confirms, on a cadence, that the Keychain contents on the single engineer's laptop and

the env vars actually running on the Hetzner host are in sync — a drift between the two would only surface as a runtime failure, not be caught proactively.

  • Register: register/assets.yaml ASSET-012 ("Secrets — dev (macOS Keychain) and prod (Hetzner

Docker env vars)") already carries classification: restricted and notes "rotated ad hoc; no formal rotation procedure yet" — this procedure is the write-up of that same honest state, not a claim that the gap is closed.

6. Evidence this procedure produces

  • The required-secrets table above, kept current as the key inventory.
  • A dated note whenever a rotation trigger fires and is acted on (who rotated what, when, why) —

no dedicated log exists yet; until one does, record this as a commit to this file's changelog or a dated entry under docs/compliance/isms/evidence/.

7. Review

Reviewed annually, and immediately after any rotation-trigger event fires for the first time (to capture whether the steps above actually worked in practice).