Extract shared `verifyWithRotation` helper and `makeSecretOrKeyProvider` into `jwt-rotation.ts` so both REST (passport-jwt strategy) and WebSocket (TokenService.verifyAccessToken) paths honour JWT_SECRET_PREVIOUS during secret rotation. Add env-validation for optional previous secrets and document the rotation policy for WebSocket sessions. Resolves GOO-237 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
8.7 KiB
Payment Gateway Secret Rotation Policy
Status: Active — GOO-197 / parent 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)
-
Generate a new high-entropy value:
openssl rand -base64 48 -
Stage the dual-key grace period. Copy the current secret to the
_PREVIOUSvariable and set the new secret as the primary:# Example for JWT_SECRET rotation: JWT_SECRET_PREVIOUS=<current-value-of-JWT_SECRET> JWT_SECRET=<newly-generated-value> # 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). -
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.
-
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).
-
Decommission the old credential in the gateway's merchant portal.
-
Remove the previous secret. After the grace period (at least one full access-token TTL cycle, typically 15 minutes), remove
JWT_SECRET_PREVIOUS(and/orJWT_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:
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<string> {
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.valuein log messages, error messages, or exception payloads.material.versionis safe to log. - Never stringify a
SecretMaterialdirectly into a response body. - For bootstrap-only contexts where
awaitis awkward, usegetSecretSync— but note that a future remote backend may throwUnsupportedSyncReadError.
6. Backends
- Short term —
EnvSecretProvider(current). Reads fromprocess.envviaConfigService. Operationally identical to the pre-existinggetOrThrow('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/AWSPREVIOUSetc.) surfaced asmaterial.version getSecretSyncmay throwUnsupportedSyncReadError; bootstrap callers must migrate togetSecret.
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
EnvSecretProviderlogs onlyname=versionpairs at boot. - The
versionis either an operator-provided<NAME>_SECRET_VERSIONenv 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.tsassert the raw value never appears in logger output, error messages, or serialized provider state. - The repo also has a global
pii-maskerandGlobalExceptionFilter— 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)
- Open a P1 incident in
#sec-incident. Page Security on-call. - Rotate the affected secret immediately following §4 — do not wait for forensic confirmation.
- Search logs / CI artifacts / git history for the leaked value
fingerprint (NOT the value itself; use
fingerprint()fromenv-secret-provider.service.ts). - Coordinate with the gateway's anti-fraud team where applicable (VNPay, MoMo, ZaloPay merchant support).
- 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
- This issue: GOO-197