# Webhook secret rotation — `FORGE_WEBHOOK_SECRET` One-page rotation SOP for `POST /api/webhooks/forge` HMAC secret. ## Where it lives - **Coolify (prod)**: `web` service env `FORGE_WEBHOOK_SECRET` in `docker/docker-compose.coolify.yml:web.environment` → Coolify UI → Environment Variables. Not committed; value lives only in Coolify env store and Forgejo webhook config. - **Local dev**: `.env` at repo root (`FORGE_WEBHOOK_SECRET=...`). Copy from `.env.example` pattern — never commit real value. - **Consumer**: `apps/web/app/api/webhooks/forge/route.ts:62` reads `process.env.FORGE_WEBHOOK_SECRET` and verifies `X-Hub-Signature-256` (GitHub) or `X-Forgejo-Signature` / `X-Hub-Signature` HMAC-SHA256 with `timingSafeEqual`. Rate-limit 60/min per IP. Missing secret → 500 `webhook_not_configured`. ## Rotate 1. Generate new secret (64 hex chars = 32 bytes): ```bash openssl rand -hex 32 ``` Copy output → new `FORGE_WEBHOOK_SECRET`. 2. Update Forgejo / GitHub webhook: - Forgejo: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save. - GitHub: Repo → Settings → Webhooks → target webhook → **Secret** → paste new value → Save / Update webhook. 3. Update Coolify: - Coolify → `echodo` service `web` → Environment Variables → set `FORGE_WEBHOOK_SECRET=` → Save. - Redeploy `web` (Coolify Deploy). Verify `docker/docker-compose.coolify.yml` not edited directly for prod; Coolify env overlay wins. 4. For local dev, update `.env`: ```bash FORGE_WEBHOOK_SECRET= pnpm --filter @tasks/web dev ``` 5. Keep old secret for 5 min overlap if Forgejo allows dual-secret testing; otherwise coordinate deploy + webhook save within same minute. ## Test Generate signature and POST to local or prod URL. Route accepts `sha256=` or bare ``. ```bash SECRET="" BODY='{"action":"closed","pull_request":{"merged":true,"body":"echodo-convoy: 00000000-0000-0000-0000-000000000000","number":123}}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') # Local curl -i -X POST http://localhost:3000/api/webhooks/forge \ -H 'Content-Type: application/json' \ -H "X-Hub-Signature-256: sha256=$SIG" \ -d "$BODY" # Forgejo variant (same HMAC, different header) curl -i -X POST http://localhost:3000/api/webhooks/forge \ -H 'Content-Type: application/json' \ -H "X-Forgejo-Signature: sha256=$SIG" \ -d "$BODY" # Prod curl -i -X POST https://echodo.stillwell.cloud/api/webhooks/forge \ -H 'Content-Type: application/json' \ -H "X-Hub-Signature-256: sha256=$SIG" \ -d "$BODY" ``` ## Verify - Valid HMAC + `action=closed` + `pull_request.merged=true` + `body` contains `echodo-convoy: ` matching a convoy in `pr_review` → 200 `{"ok":true}` and convoy transitions `pr_review → merged` (`automation`, `pr#N merged`) visible in `convoy_events` and Mission Control within 5s. - Invalid signature → 401 `{"ok":false,"error":"invalid_signature"}`. - Missing `echodo-convoy:` marker or `merged=false` or `status !== pr_review` → 200 `{"ok":true}` no-op (no leak). - Missing `FORGE_WEBHOOK_SECRET` env → 500 `webhook_not_configured`. ## References - Route: `apps/web/app/api/webhooks/forge/route.ts` - State machine: `packages/shared/src/convoy-lifecycle/state-machine.ts:90` - Checklist: `docs/operations/live-e2e-checklist.md`