Skip to content

Secrets Management

Statement

ClaimGuard's runtime secrets — JWT_SECRET, DATABASE_URL, and API_KEY — live in GCP Secret Manager. The backend's bootstrap module (server/src/lib/secrets.js) fetches them at process start via the VM's attached service account (the workload service account) using Application Default Credentials, gated by USE_SECRET_MANAGER='1' in the pm2 ecosystem env. Backend fail-closed assertions in auth.js and db/pool.js exit the process non-zero on boot if any required secret is absent — there is no fallback to a static value, and there is no hardcoded secret value anywhere on the runtime path. No long-lived secret material is checked into source or baked into container images.

Implementation

What's stored in Secret Manager

Secret name Purpose
the JWT secret HMAC signing key for user JWTs (server/src/middleware/auth.js).
the database URL secret Postgres connection string used by server/src/db/pool.js.
an internal-services API-key secret internal analysis API key used by an internal analysis service.

Each secret has replication-policy=automatic and is independently versioned. Adding a new secret requires (a) creating it in Secret Manager, (b) granting roles/secretmanager.secretAccessor to the runtime SA on that secret only, and (c) adding the env-name → secret-name mapping in server/src/lib/secrets.js.

How the application loads them

The bootstrap module server/src/lib/secrets.js runs as a top-level side-effect import at the very top of server/src/index.js:

  1. Loads the local .env via dotenv.config() (always — supports both dev and prod).
  2. If USE_SECRET_MANAGER=1, fills any still-missing keys from Secret Manager via Application Default Credentials (the attached the workload service account SA).
  3. Returns. The existing fail-closed assertions in auth.js and db/pool.js then run as normal — if JWT_SECRET or DATABASE_URL is unset at this point, the process exits non-zero with a loud error.

Live state in prod (verified 2026-05-03 after cutover, over IAP-SSH): - The backend-api pm2 process env contains USE_SECRET_MANAGER=1 and GCP_PROJECT=<project>, with no hardcoded JWT_SECRET / DATABASE_URL / API_KEY values in the ecosystem block. Confirmed by reading /proc/$(pgrep -f 'node.*src/index.js')/environ. - secrets.js fetched all three values from Secret Manager at the most recent backend boot. The fail-closed assertions in middleware/auth.js and db/pool.js were satisfied by those fetched values; the backend started cleanly, [DB] Connected to PostgreSQL logged, /health returns HTTP 200. - ADC end-to-end was independently proved out before the cutover by minting a metadata-server token and calling secretmanager.googleapis.com/.../accessSecretVersion directly — HTTP 200 with the SA's permissions. - JWT_SECRET was rotated to a fresh strong random hex in SM immediately before the cutover (the JWT secret version 2). All previously-issued user tokens were invalidated; users re-authenticated on next request. - The previous ecosystem.prod.config.cjs (with hardcoded placeholder JWT_SECRET and literal DATABASE_URL) is retained as a timestamped .bak next to the live file for short-term rollback only.

ESM's depth-first evaluation order guarantees the env is populated before any sibling import of index.js evaluates its module-level code. The bootstrap is therefore strictly before middleware initialization regardless of which source provides the values; there is no window in which an HTTP handler runs against an un-initialized config.

Service account binding

The runtime service account the workload service account holds roles/secretmanager.secretAccessor on each secret individually — no project-level secret-accessor role, no project-Editor. The full grant inventory is:

  • Project level: roles/logging.logWriter, roles/monitoring.metricWriter.
  • Per secret: roles/secretmanager.secretAccessor on the three the runtime secrets above.

This means if the VM is compromised, the blast radius for secret access is exactly those three values — not "everything in this GCP project."

Audit trail

Every AccessSecretVersion call is recorded in the project's _Required audit log bucket (Admin Activity, 400-day immutable retention). See Audit logging. A representative query:

gcloud logging read \
  'protoPayload.serviceName="secretmanager.googleapis.com"
   AND protoPayload.methodName="google.cloud.secretmanager.v1.SecretManagerService.AccessSecretVersion"' \
  --project=<project> --limit=50 --format=json

Rotation

JWT_SECRET rotation invalidates every issued user token, so it is performed on a documented cadence with comms (planned — see roadmap below). Rotating DATABASE_URL happens during database credential rotation. Rotating API_KEY is a one-step Secret Manager add-version plus a backend restart.

The rotation procedure for any secret is:

printf '%s' "$NEW_VALUE" | \
  gcloud secrets versions add claim-guard-<name> \
  --data-file=- --project=<project>
pm2 restart claim-guard-server   # or VM restart

Old versions are retained until the operator runs gcloud secrets versions destroy — they are not auto-pruned.

Status

implemented — cutover completed 2026-05-03.

The Secret Manager bootstrap was added as plan step an early plan step and is recorded in the internal hardening journal (entry: an early plan step — Move runtime secrets into Secret Manager). The the workload service account SA holds per-secret secretAccessor on all three the runtime secrets (verified 2026-05-01 in B.1 audit). The USE_SECRET_MANAGER gate was activated in production on 2026-05-03 along with removal of all hardcoded secret values from ecosystem.prod.config.cjs. The runtime data path now exercises Secret Manager on every backend boot. JWT_SECRET was rotated to a fresh strong random hex during the cutover; the prior placeholder value (literal string 'YOUR_PRODUCTION_JWT_SECRET' that had been set in the pre-cutover platform configuration) is no longer in use anywhere. Implementation details are available on NDA request.

Known gaps and roadmap

  • Python tools still read literal values from their own .env files ((internal analysis service).env, (content-provenance service).env). The Node.js server's Secret Manager bootstrap does not affect these — the Python services have a separate env-loading path. Migrating them to ADC + the same SA is tracked as a P2 follow-up. The internal analysis service API_KEY is being rotated as part of the same 2026-05-03 cleanup pass.
  • Static service-account JSON key on the VM for the content-provenance service Python tool (a static service-account JSON key). Known anti-pattern; migration to ADC is a P1 follow-up logged in the hardening journal.
  • JWT_SECRET rotation cadence is not yet on a calendar. Plan step the JWT-rotation runbook schedules the first rotation alongside user comms.