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:
- 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 attached the workload service account SA). - 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=<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.secretAccessoron 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
.envfiles ((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. Theinternal analysis serviceAPI_KEYis 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.