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:
- Loads the local
.envviadotenv.config()(always — supports both dev and prod). - If
USE_SECRET_MANAGER=1, fills any still-missing keys from Secret Manager via Application Default Credentials (the attachedclaim-guard-vmSA). - Returns. The existing fail-closed assertions in
auth.jsanddb/pool.jsthen run as normal — ifJWT_SECRETorDATABASE_URLis 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.secretAccessoron the threeclaim-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
.envfiles (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. Thetool-masterGOOGLE_API_KEYis 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.