Skip to content

Secrets Management

Statement

ClaimGuard's runtime secrets — JWT_SECRET, DATABASE_URL, and GOOGLE_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 (claim-guard-vm@) 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
claim-guard-jwt-secret HMAC signing key for user JWTs (server/src/middleware/auth.js).
claim-guard-database-url Postgres connection string used by server/src/db/pool.js.
claim-guard-google-api-key Gemini API key used by the master_tool 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 claim-guard-vm 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=train-cvit2, with no hardcoded JWT_SECRET / DATABASE_URL / GOOGLE_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 (claim-guard-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 claim-guard-vm@train-cvit2.iam.gserviceaccount.com 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 claim-guard-* 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=train-cvit2 --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 GOOGLE_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=train-cvit2
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 A0.3 and is recorded in docs/security/HARDENING-LOG.md (entry: A0.3 — Move runtime secrets into Secret Manager). The claim-guard-vm SA holds per-secret secretAccessor on all three claim-guard-* 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 ecosystem.prod.config.cjs) is no longer in use anywhere. Code: server/src/lib/secrets.js.

Known gaps and roadmap

  • Python tools still read literal values from their own .env files (tools/master_tool/.env, tools/c2pa/.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 tool-master GOOGLE_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 c2pa Python tool (google-lens-image-processor-creds.json). 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 A2.5 schedules the first rotation alongside user comms.