Secrets rotation policy
Source of truth for every secret the gateway holds: name, scope, storage location, rotation cadence, and procedure. Closes SEC-09 from the launch-readiness audit.
Automation reminder: .github/workflows/secret-rotation-reminders.yml runs monthly, reads .github/secrets-inventory.yml, and opens a secret rotation due: <name> issue labelled ops / security whenever a secret's last_rotated plus its interval_days is within 30 days of today. The inventory file is the canonical cadence list — when you rotate a secret, bump last_rotated there.
Scope note. This runbook documents rotation. It does not document provisioning (that lives in
infra/terraform/modules/secrets/main.tf) or access-grant patterns (ininfra/terraform/modules/iam/). If a rotation procedure produces a new secret resource, provisioning happens via Terraform and rotation then takes over from there.
Inventory
All secrets live in GCP Secret Manager under project pinpoint-gateway unless otherwise noted. Keys are CMEK-encrypted (var.kms_key_id). Staging and production share Secret Manager but use distinct secret_id values where listed.
Secret secret_id | Scope | Storage | Cadence | Rotation procedure |
|---|---|---|---|---|
gateway-database-url | Production | Secret Manager | On-demand only | DB URL |
gateway-database-url-staging | Staging | Secret Manager | On-demand only | DB URL |
gateway-internal-api-shared-secret | Shared (cross-service) | Secret Manager, autogen via random_password | 180 days | Internal shared secret |
oauth-signing-key | Shared (auth service issues, public services verify via JWK) | Secret Manager | 90 days | OAuth signing key |
transit-developer-id | Shared (TransIT identity) | Secret Manager | TSYS-driven (see procedure) | TransIT developer ID |
xtms-app-id | Shared (NexGO device mgmt) | Secret Manager | NexGO-driven / annual | XTMS app credentials |
xtms-app-key | Shared (NexGO device mgmt) | Secret Manager | NexGO-driven / annual | XTMS app credentials |
apple-pay-platform-integrator-id | Shared (Apple Pay identity) | Secret Manager | Apple-driven / annual | Apple Pay identity cert |
apple-pay-integrator-identity-certificate | Shared | Secret Manager (uploaded out-of-band via gcloud) | 25 months (Apple-driven) | Apple Pay identity cert |
apple-pay-integrator-identity-certificate-key | Shared | Secret Manager (out-of-band) | matches identity cert | Apple Pay identity cert |
apple-pay-payment-processing-certificate | Shared | Secret Manager (out-of-band) | 25 months (Apple-driven) | Apple Pay payment processing cert |
apple-pay-payment-processing-key | Shared | Secret Manager (out-of-band) | matches processing cert | Apple Pay payment processing cert |
checkout-session-secret | Production (HMAC) | Secret Manager, autogen via random_password | 180 days | Checkout session HMAC |
checkout-session-secret-staging | Staging (HMAC) | Secret Manager, autogen via random_password | 180 days | Checkout session HMAC |
| Firebase Admin SDK service account key | Shared (if used by management for Firebase Auth verification) | Secret Manager as a GCP service account JSON key, OR Workload Identity Federation (WIF) with no persistent key material | 90 days if JSON key; n/a with WIF | Firebase Admin SDK |
| GCP service account keys (deploy pipelines) | CI / CD | GitHub Actions secrets via WIF (WIF_PROVIDER, WIF_SERVICE_ACCOUNT) | WIF → no key material to rotate; underlying SA impersonation policy reviewed annually | Deploy SA keys |
| Internal service-to-service ID tokens | Ephemeral (issued per-request) | Not stored | n/a — short-lived, issued by GCP IAM | Internal ID tokens |
"On-demand only" means rotation is driven by an external event (breach, compromise, Spanner instance rename) rather than a calendar; the reminders workflow still tracks the secret but does not open an issue for it unless the interval_days is set.
Per-secret procedures
DB URL (Spanner connection string)
The gateway-database-url and gateway-database-url-staging values are plain JDBC connection strings pointing at the Spanner instance; they carry no credentials of their own because Spanner authenticates via GCP IAM (Cloud Run services use their service-account identity, no static DB username/password exists).
- What rotates: the SA identity and the IAM bindings that grant Spanner access, not the URL itself.
- When to rotate: Cloud Run service account impersonation policy is reviewed annually, or on an incident where an SA is suspected of compromise.
- Procedure:
- In
infra/terraform/modules/iam/confirm the list of SAs withroles/spanner.databaseUser. Remove any SA no longer owned by an active Cloud Run service. - If rotating an SA (not the URL): create a new SA in Terraform, grant
roles/spanner.databaseUser, update the Cloud Run service to use it, then remove the old binding after one week of overlap. - The URL itself only changes if the Spanner instance is renamed — a rare event, typically tied to a region migration or a DR restore (see Spanner DR runbook).
- In
Internal API shared secret
Autogenerated by Terraform (random_password.internal_api_shared_secret). Used by internal service clients as a secondary signal alongside Cloud Run IAM — defense in depth, not the primary auth layer.
- Cadence: 180 days.
- Procedure:
- Run
terraform -chdir=infra/terraform taint 'module.secrets.random_password.internal_api_shared_secret'thenterraform apply. Terraform generates a new value and creates a new Secret Manager version. - The Cloud Run services that mount this secret read
latestby default, so the next revision rollout picks up the new value automatically. To force immediate pickup without a full deploy, restart the Cloud Run services:gcloud run services update gateway-<service> --region=us-east1 --update-env-vars=ROTATION_BUMP=$(date +%s)(any env-var change forces a revision roll). - Bump
last_rotatedin.github/secrets-inventory.yml.
- Run
OAuth signing key
RSA key used by the auth service to sign OAuth2 access tokens and ID tokens; verified by public-facing services via the JWK endpoint. Rotation is the highest-risk operation on this list because misplaying it invalidates every in-flight token.
- Cadence: 90 days.
- Overlap window: the new key must be active for verification before it starts being used for signing. Never flip both in one step.
- Procedure:
- Generate a new 2048-bit RSA key locally:
openssl genrsa -out /tmp/oauth-signing-key-$(date +%Y%m%d).pem 2048
- Upload as a new version of
oauth-signing-key:gcloud secrets versions add oauth-signing-key \--project=pinpoint-gateway \--data-file=/tmp/oauth-signing-key-$(date +%Y%m%d).pem - The auth service loads all enabled versions of the secret and publishes every public key to the JWK endpoint. Confirm both the old and the new key are present:
Expected: 2. If the auth service only publishes one key, it has not yet reloaded — roll a new revision to force pickup.curl -s https://auth.pinpointgateway.com/.well-known/jwks.json | jq '.keys | length'
- Verification overlap window: leave the new key at "verify only" for 24 hours. Public services cache JWK responses; 24h ensures every instance has refreshed at least once.
- Flip the auth service to sign with the new key. This is controlled by the
OAUTH_SIGNING_KEY_VERSIONenv var on the auth Cloud Run service:gcloud run services update gateway-auth \--project=pinpoint-gateway \--region=us-east1 \--update-env-vars=OAUTH_SIGNING_KEY_VERSION=$(gcloud secrets versions list oauth-signing-key --project=pinpoint-gateway --limit=1 --format='value(name)') - Retire the old key after 24 more hours (so any access token issued in the last 60 minutes before the flip has expired). Disable the old Secret Manager version:
gcloud secrets versions disable <OLD_VERSION_NUMBER> \--secret=oauth-signing-key \--project=pinpoint-gateway
- Bump
last_rotatedin.github/secrets-inventory.yml.
- Generate a new 2048-bit RSA key locally:
If any step fails mid-rotation, do not delete the old key. The only safe recovery is to flip OAUTH_SIGNING_KEY_VERSION back to the old version number and investigate.
TransIT developer ID
TSYS-assigned identifier tied to the TransIT merchant. The dev cert expires on TSYS's calendar (typically annually for dev, per-merchant for production). TSYS emails a renewal notice ~60 days before expiry.
- Cadence: TSYS-driven (typically annual). Reminder workflow treats it as 365 days for alerting purposes.
- Procedure:
- When the renewal email arrives from Global Payments, follow their cert-renewal flow in the TSYS merchant portal.
- Download the new cert bundle.
- Upload as a new version in Secret Manager:
gcloud secrets versions add transit-developer-id \--project=pinpoint-gateway \--data-file=/path/to/new/bundle
- Cloud Run services mounting
transit-developer-idpick up the latest version on next revision roll. Force a roll by restarting theprocessingservice. - Run a smoke TransIT call via the cert runner (
./tools/certification/run-all-tabs.sh --tabs ecommerce --skip-setup) to verify the new cert is accepted upstream. - Retire the old version only after a clean smoke run.
- Bump
last_rotatedin.github/secrets-inventory.yml.
XTMS app credentials
NexGO XTMS app ID and app key, used for device provisioning. NexGO issues these per integrator account; rotation is rare and annual unless NexGO rotates them on their end.
- Cadence: annual (calendar-driven), or on NexGO's notice.
- Procedure:
- In the NexGO XTMS portal, regenerate the app key (the app ID is stable; only the key rotates).
- Upload the new key:
gcloud secrets versions add xtms-app-key \--project=pinpoint-gateway \--data-file=/tmp/xtms-app-key-$(date +%Y%m%d).txt
- Restart the
device-provisioningservice (or the service that owns XTMS calls — per CLAUDE.md this is handled inside the management service via a client). Monitor logs forXTMSrequest failures over the next hour. - Bump
last_rotatedin.github/secrets-inventory.yml.
Apple Pay platform integrator identity certificate
This is the identity cert Apple issues when you register as a Platform Integrator. It proves that the Peak Gateway platform is who it says it is to Apple Pay infrastructure.
- Cadence: 25 months (Apple issues these with a 25-month validity; plan rotation at 24 months).
- Consequence of missing rotation: merchants stop being able to enroll new Apple Pay domain associations. Existing tokenization is not immediately broken, but net new merchant onboarding to Apple Pay halts.
- Procedure:
- At T-60 days: generate a new CSR:
openssl req -new -newkey rsa:2048 -nodes \-keyout apple-pay-integrator-identity.key \-out apple-pay-integrator-identity.csr \-subj "/CN=PeakGateway Platform Integrator/O=Peak Gateway/C=US"
- Submit the CSR to Apple via the Apple Pay Platform Integrator portal. Wait for Apple to sign and return the cert.
- Upload both halves as new versions:
gcloud secrets versions add apple-pay-integrator-identity-certificate \--project=pinpoint-gateway --data-file=apple-pay-integrator-identity.pemgcloud secrets versions add apple-pay-integrator-identity-certificate-key \--project=pinpoint-gateway --data-file=apple-pay-integrator-identity.key
- Force a roll on services that mount these secrets (processing + online-txn).
- Verify by running the Apple Pay tab of the certification suite against the staging merchant:
./tools/certification/run-all-tabs.sh --tabs in-app --skip-setup. - Retire old versions only after a successful Apple Pay session in staging.
- Bump
last_rotatedin.github/secrets-inventory.yml.
- At T-60 days: generate a new CSR:
Apple Pay payment processing certificate
This is the per-merchant-class cert that decrypts Apple Pay payment tokens. Missing this rotation breaks production tokenization — every Apple Pay transaction fails until the new cert is in place.
- Cadence: 25 months (Apple-issued). Target rotation at 24 months.
- Procedure:
- Follow the same CSR flow as the identity cert but through the Apple Pay Payment Processing Certificate section of the portal.
- Upload both halves:
gcloud secrets versions add apple-pay-payment-processing-certificate \--project=pinpoint-gateway --data-file=apple-pay-processing.pemgcloud secrets versions add apple-pay-payment-processing-key \--project=pinpoint-gateway --data-file=apple-pay-processing.key
- Critical: Apple Pay payment decryption supports a rollover window — tokens encrypted under the old cert can still be decrypted as long as the old Secret Manager version remains enabled. Keep the old version enabled for at least 14 days after the new cert goes live (Apple Pay tokens have a time window before expiry).
- Verify end-to-end: run a real Apple Pay sandbox transaction through the staging checkout and confirm the processing service decrypts the token successfully.
- Bump
last_rotatedin.github/secrets-inventory.yml.
If decryption starts failing after the new cert is deployed: re-enable the old version immediately; investigate before retrying.
Checkout session HMAC
HMAC secret used to sign checkout-session tokens issued by the online-txn service. Autogenerated by Terraform (random_password.checkout_session_secret). Rotation needs a dual-key rollover so sessions issued under the old key still validate during cutover.
- Cadence: 180 days.
- Expected session TTL: checkout sessions are short (~60 min), so the overlap window is modest.
- Procedure:
- Add a new version:
terraform -chdir=infra/terraform taint 'module.secrets.random_password.checkout_session_secret'terraform -chdir=infra/terraform apply
- The online-txn service must be able to verify signatures using either the old or the new key, while issuing new sessions with the old key for the first overlap period, then flipping to the new key. The service reads both
CHECKOUT_SESSION_SECRET_CURRENTandCHECKOUT_SESSION_SECRET_PREVIOUSenv vars. - During rotation:
- Step A: deploy with
CURRENT=old,PREVIOUS=new. Both validate; issuance uses old. - Step B (2 hours later): flip to
CURRENT=new,PREVIOUS=old. Both validate; issuance uses new. - Step C (2 hours after B — long enough for any session issued under old to have expired): drop
PREVIOUS.
- Step A: deploy with
- After step C, disable the old Secret Manager version.
- Bump
last_rotatedin.github/secrets-inventory.yml.
- Add a new version:
Staging (checkout-session-secret-staging) follows the same procedure but can be rotated without overlap during off-peak hours.
Firebase Admin SDK key
Used by the management service to verify Firebase-issued ID tokens from the admin portal. Two deployment modes:
- Workload Identity Federation (preferred): the management Cloud Run service's SA is granted
roles/firebase.adminon the Firebase project. No persistent key material — nothing to rotate. Reminder workflow does not track this case. - JSON key (legacy / local dev only): if the service mounts a Firebase SA JSON key, that key must rotate every 90 days.
- Procedure (JSON key only):
- In the GCP console → IAM → Service Accounts →
<firebase-admin-sa>@...→ Keys → Create new key (JSON). - Upload as a new secret version:
gcloud secrets versions add firebase-admin-sdk-key \--project=pinpoint-gateway --data-file=/tmp/firebase-sa.json
- Roll the management service to pick it up.
- In IAM, delete the old key (not just disable it — SA keys are expensive attack surface).
- Bump
last_rotatedin.github/secrets-inventory.yml.
- In the GCP console → IAM → Service Accounts →
Deploy pipeline service account keys
GitHub Actions authenticates to GCP via Workload Identity Federation (WIF_PROVIDER + WIF_SERVICE_ACCOUNT GitHub secrets). No persistent key material exists. Rotation consists of:
- Annual review of the WIF pool attribute mappings and the list of SAs GitHub is allowed to impersonate.
- Immediate action if a workflow is exfiltrated or a third party gains write access to the repo: revoke the WIF binding on the target SA in Terraform and re-apply.
No per-secret last_rotated tracking.
Internal service ID tokens
The InternalServiceClient in libs/security fetches a Google IAM ID token for each outbound service-to-service call. Tokens are signed by Google, valid for 1 hour, cached in-process.
- Rotation: automatic. Google reissues on expiry.
- What can break: if ID token exchange starts failing (
401 Unauthorizedon service-to-service calls):- Check the calling service's SA has
roles/run.invokeron the target service ininfra/terraform/modules/iam/. - Confirm the target service's Cloud Run config requires authentication (
--no-allow-unauthenticated). - Check the GCP metadata server is reachable from the Cloud Run instance (
curltohttp://metadata.google.internal). - If all of the above are green but tokens are still rejected, file a GCP support ticket — the IAM token service is degraded.
- Check the calling service's SA has
Automation details
.github/workflows/secret-rotation-reminders.yml reads .github/secrets-inventory.yml every month on the first at 14:00 UTC, computes last_rotated + interval_days, and opens an issue for each secret whose rotation window is within 30 days of today (or is already overdue).
Inventory schema:
secrets:
- name: oauth-signing-key
interval_days: 90
last_rotated: "2026-01-15"
owner: security-team
runbook: secrets-rotation#oauth-signing-key
When a secret is rotated, the only bookkeeping required is bumping last_rotated in the inventory and closing the corresponding issue. The workflow is idempotent: if an open issue titled secret rotation due: <name> already exists, it does not open a duplicate.
To adjust cadence, edit interval_days. To retire a secret, remove it from secrets-inventory.yml; the workflow will stop tracking it the following month.