# Payment Gateway Secret Rotation Policy > **Status:** Active — GOO-197 / parent [GOO-102](/GOO/issues/GOO-102) (CLO data-security work). > **Owner:** Security Engineer + Platform on-call. > **Last reviewed:** 2026-04-24. This document is the canonical policy for rotating all secrets that gate access to GoodGo's payment gateways and adjacent integrations (OAuth, storage, webhook signing, JWT). It ships alongside the `SecretProvider` abstraction in `apps/api/src/modules/shared/domain/ports/secret-provider.port.ts` and the env-backed implementation in `apps/api/src/modules/shared/infrastructure/env-secret-provider.service.ts`. --- ## 1. Why rotate A stolen or leaked HMAC key for a payment gateway is the most direct path to financial fraud against GoodGo. Rotation reduces the **window of abuse** when a key is exposed (insider misuse, accidental git commit, third-party breach, log scraping, etc.). It also forces us to verify that every runtime that relies on the key can still read it — i.e. that we have not lost the ability to rotate. ## 2. Scope (rotation-sensitive secrets) The following secrets are in scope. Each is registered with the `SecretProvider` by default (see `DEFAULT_REGISTERED_SECRETS`) and has a matching entry in `env-validation.ts`. | Secret env var | Purpose | Cadence | Owner | | ------------------------------ | ------------------------------- | -------- | -------------- | | `JWT_SECRET` | Access-token HMAC | 90 days | Auth | | `JWT_REFRESH_SECRET` | Refresh-token HMAC | 90 days | Auth | | `VNPAY_HASH_SECRET` | VNPay request/callback HMAC | 90 days | Payments | | `MOMO_SECRET_KEY` | MoMo request/callback HMAC | 90 days | Payments | | `ZALOPAY_KEY1` | ZaloPay order signing | 90 days | Payments | | `ZALOPAY_KEY2` | ZaloPay callback signing | 90 days | Payments | | `BANK_TRANSFER_WEBHOOK_SECRET` | Bank-transfer webhook signature | 90 days | Payments | | `GOOGLE_CLIENT_SECRET` | Google OAuth | 180 days | Auth | | `ZALO_APP_SECRET` | Zalo OAuth | 180 days | Auth | | `ZALO_OA_ACCESS_TOKEN` | Zalo Official Account API token | 90 days | Notifications | | `MINIO_SECRET_KEY` | Object-storage access key | 180 days | Platform | | `FIELD_ENCRYPTION_KEY` | At-rest PII encryption key | annually | Platform + CLO | Secrets **not** in this table (e.g. `DATABASE_URL` password, `REDIS_HOST`) follow the platform-credential rotation policy and are out of scope here. ## 3. Cadence and triggers - **Routine rotation:** every 90 days for HMAC/signing keys, 180 days for OAuth client secrets, annually for the field-encryption key (which has expensive data-rewrap implications). - **Event-driven rotation (always immediately):** - any commit accidentally containing a real value of one of the secrets above (regardless of how briefly); - departure of any individual with production access to the secret store; - downstream provider notification that the credential may be exposed; - confirmed or strongly suspected breach of any system that handled the secret in plaintext (CI runner, dev laptop, log aggregator, …). ## 4. Operator workflow (env-backed backend) 1. **Generate** a new high-entropy value: ```bash openssl rand -base64 48 ``` 2. **Stage the dual-key grace period.** Copy the current secret to the `_PREVIOUS` variable and set the new secret as the primary: ```bash # Example for JWT_SECRET rotation: JWT_SECRET_PREVIOUS= JWT_SECRET= # Same pattern for JWT_REFRESH_SECRET if rotating refresh keys. ``` The auth layer automatically tries the primary key first and falls back to `_PREVIOUS`, so tokens signed with the old key continue to validate during the grace period (≤ access-token TTL, typically 15 m). 3. **Deploy** the change. On boot, every API instance logs: ``` [EnvSecretProvider] Secret versions at boot: VNPAY_HASH_SECRET=2026-04-24, … ``` Verify the version field matches the staged version on every instance. The raw value **must never** appear in this or any other log line. 4. **Smoke-test** payment flows for the rotated provider: - issue one sandbox payment - confirm callback verification succeeds - confirm refund signing succeeds Record the rotation in the security audit log (`docs/security/secret-rotation-log.md` — append-only). 5. **Decommission** the old credential in the gateway's merchant portal. 6. **Remove the previous secret.** After the grace period (at least one full access-token TTL cycle, typically 15 minutes), remove `JWT_SECRET_PREVIOUS` (and/or `JWT_REFRESH_SECRET_PREVIOUS`) from the environment and redeploy. This closes the dual-key window. ## 5. SecretProvider abstraction (developer workflow) All new and existing code that consumes a rotation-sensitive secret MUST go through the `SecretProvider` port: ```ts import { Inject, Injectable } from '@nestjs/common'; import { SECRET_PROVIDER, type ISecretProvider } from '@modules/shared/domain/ports'; @Injectable() export class VnpayService { constructor(@Inject(SECRET_PROVIDER) private readonly secrets: ISecretProvider) {} async sign(payload: string): Promise { const { value } = await this.secrets.getSecret('VNPAY_HASH_SECRET'); // … HMAC with `value`, never store it on `this`, never log it. } } ``` Rules: - **Never** capture the raw value into a service field. Always re-read on the request path so a rotation takes effect at the next request. - **Never** include `material.value` in log messages, error messages, or exception payloads. `material.version` is safe to log. - **Never** stringify a `SecretMaterial` directly into a response body. - For bootstrap-only contexts where `await` is awkward, use `getSecretSync` — but note that a future remote backend may throw `UnsupportedSyncReadError`. ## 6. Backends - **Short term — `EnvSecretProvider` (current).** Reads from `process.env` via `ConfigService`. Operationally identical to the pre-existing `getOrThrow('VNPAY_HASH_SECRET')` calls, but with a stable audit surface (versions logged, port-based DI). - **Mid term — `AwsSecretsManagerSecretProvider` / `VaultSecretProvider`.** Same port. Adds: - automatic refresh from the remote store - per-secret IAM / Vault-policy scoping - native version ids (`AWSCURRENT` / `AWSPREVIOUS` etc.) surfaced as `material.version` - `getSecretSync` may throw `UnsupportedSyncReadError`; bootstrap callers must migrate to `getSecret`. Switching backends is a one-line change in `SharedModule` (replace `EnvSecretProvider` with the new implementation under the `SECRET_PROVIDER` token). No call sites change. ## 7. Logging discipline - The `EnvSecretProvider` logs only `name=version` pairs at boot. - The `version` is either an operator-provided `_SECRET_VERSION` env var, or a 10-char SHA-256 fingerprint of the value (40 bits of entropy; non-invertible; useful for distinguishing rotations across instances). - Negative tests in `apps/api/src/modules/shared/infrastructure/__tests__/env-secret-provider.service.spec.ts` assert the raw value never appears in logger output, error messages, or serialized provider state. - The repo also has a global `pii-masker` and `GlobalExceptionFilter` — those are defence-in-depth, not the primary control. The primary control is "never put the value into a string in the first place." ## 8. Incident response (suspected leak) 1. Open a P1 incident in `#sec-incident`. Page Security on-call. 2. Rotate the affected secret immediately following §4 — do not wait for forensic confirmation. 3. Search logs / CI artifacts / git history for the leaked value fingerprint (NOT the value itself; use `fingerprint()` from `env-secret-provider.service.ts`). 4. Coordinate with the gateway's anti-fraud team where applicable (VNPay, MoMo, ZaloPay merchant support). 5. File a post-mortem within 5 business days; update this policy if process gaps were found. ## 9. References - Source port: `apps/api/src/modules/shared/domain/ports/secret-provider.port.ts` - Env-backed impl: `apps/api/src/modules/shared/infrastructure/env-secret-provider.service.ts` - Env validation: `apps/api/src/modules/shared/infrastructure/env-validation.ts` - Negative tests: `apps/api/src/modules/shared/infrastructure/__tests__/env-secret-provider.service.spec.ts` - Parent issue: [GOO-102](/GOO/issues/GOO-102) - This issue: [GOO-197](/GOO/issues/GOO-197)