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
- Dev: add it to Keychain —
security add-generic-password -a "$USER" -s STRATIFY_<NAME> -w "<value>" -U- 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.
- 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.
- 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)
| Variable | Purpose |
|---|---|
STRATIFY_SUPABASE_SERVICE_ROLE | Server-only Supabase service role — the de facto admin bypass for RLS (register/facts.md). Never exposed to the browser. |
STRATIFY_STRIPE_SECRET_KEY | Server-side Stripe SDK. |
STRATIFY_STRIPE_WEBHOOK_SECRET | Verifies inbound Stripe webhooks. |
STRATIFY_SUPABASE_WEBHOOK_SECRET | Verifies inbound Supabase auth-hook webhooks. |
AWS_SES_ACCESS_KEY_ID / AWS_SES_SECRET_ACCESS_KEY | Amazon SES credentials — the current transactional-email vendor, replaced Resend (apps/web/lib/email/ses.ts). |
AWS_SES_SMTP_PASSWORD | Derived SMTP password, used by GoTrue's mailer for OTP/recovery/invite codes. |
STRATIFY_VAPID_PRIVATE_KEY | Web-push signing key. |
STRATIFY_SENTRY_AUTH_TOKEN | Source-map upload, CI-only. |
STRATIFY_DATABASE_URL | Direct Postgres connection for migrations/scripts. |
STRATIFY_AI_GATEWAY_API_KEY | Vercel AI Gateway → Anthropic, for signal-drafting assist. |
STRATIFY_EXPO_ACCESS_TOKEN | Expo Push API auth. |
STRATIFY_CRON_SECRET | Bearer token for /api/cron/* endpoints. |
STRATIFY_PARTNER_WEBHOOK_SECRET | HMAC 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:
| Trigger | Action |
|---|---|
| Engineer leaves | Rotate every secret they could have read. Service-role key first. |
| Partner-key suspected compromised | Revoke in partner_api_keys, issue replacement via /admin/partners. |
| Stripe webhook secret leaked | Rotate in Stripe Dashboard, re-add STRATIFY_STRIPE_WEBHOOK_SECRET. |
| Audit-chain divergence | Treat 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.yamlASSET-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).