Short answer: use two narrowly scoped API keys, switch traffic with an explicit activation step, and revoke the old key only after logs and live requests prove the cutover. For a property-management service, that sequence rotates a production credential without taking rent, work-order, or tenant-notification flows offline.
The hard part is attribution. A shared CI secret can make every building, job, and deployment look like the same caller on a bill. A leaked build log then becomes both a security incident and an accounting dispute. The fix is a small control plane around the key, not a larger token pasted into more places.
The field guide: six checks before a key enters CI
| Check | What to verify | Failure it prevents |
|---|---|---|
| Scope | Read-only or one mutation family, with a tenant or environment boundary | A test job changing production leases |
| Identity | Key maps to a service identity and billing owner, not a developer | Unclear attribution on a monthly bill |
| Exposure | Secret never appears in command arguments or echoed variables | Build-log replay |
| Rotation | New key can be active beside the old one | Downtime during deployment |
| Observation | Requests, key ID, workflow run, and tenant are correlated | Guessing which job used a leaked key |
| Retirement | Old key has a tested revoke path and an owner | Forgotten credentials that live forever |
Pick a dual-key cutover when the API allows overlapping credentials and your deploys cannot pause tenant-facing traffic. Pick a brokered exchange when policy forbids CI from seeing a long-lived production secret; the workflow receives a short-lived token instead. Pick a manual change window when the provider has no overlap or revoke operation, but document the outage budget before you call it a rotation.
That last option is a constraint, not a strategy. Treat it as a gap to close.
How should a Node.js GitHub Actions pipeline rotate a scoped API key?
Think in four lanes: issue, distribute, observe, retire. The issue lane creates a key with the smallest useful permission. Distribution gives it to one job through the platform's secret store. Observation joins the key fingerprint to a workflow run. Retirement revokes the previous key after a quiet period.
Here is a minimal Node.js client. The endpoint names are placeholders for your account platform's documented API; the important contract is method, scope, and response handling. Keep the key value out of logs, exceptions, and test snapshots.
type KeyScope = {
environment: "production";
tenantIds: string[];
actions: string[];
};
type IssuedKey = {
id: string;
value: string;
createdAt: string;
};
const baseUrl = process.env.ACCOUNT_API_URL;
const adminToken = process.env.ACCOUNT_ADMIN_TOKEN;
if (!baseUrl || !adminToken) {
throw new Error("Missing account API configuration");
}
async function issueScopedKey(scope: KeyScope): Promise<IssuedKey> {
const response = await fetch(`${baseUrl}/keys`, {
method: "POST",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
"idempotency-key": `property-ci-${scope.tenantIds.join("-")}`,
},
body: JSON.stringify({ name: "property-ci-rotation", scope }),
});
if (!response.ok) {
throw new Error(`Key issue failed with status ${response.status}`);
}
return (await response.json()) as IssuedKey;
}
const issued = await issueScopedKey({
environment: "production",
tenantIds: ["building-017", "building-021"],
actions: ["leases:read", "work-orders:write"],
});
// Pass issued.value to the secret manager here. Never print it.
console.log(JSON.stringify({ keyId: issued.id, createdAt: issued.createdAt }));
The administrative token belongs in a protected rotation job, not in every pull-request job. The application job gets only the scoped runtime key. In GitHub Actions, map that secret to an environment variable at the step boundary and avoid shell tracing. A failed request should report an HTTP status and correlation ID, never the Authorization header.
const runtimeKey = process.env.PROPERTY_RUNTIME_KEY;
if (!runtimeKey) throw new Error("PROPERTY_RUNTIME_KEY is required");
const result = await fetch(`${process.env.ACCOUNT_API_URL}/leases`, {
method: "GET",
headers: { authorization: `Bearer ${runtimeKey}` },
});
console.log(JSON.stringify({
ok: result.ok,
status: result.status,
requestId: result.headers.get("x-request-id"),
}));
The code has an intentionally boring shape. Boring is observable. A log parser can allow keyId, status, and requestId, then reject values that look like bearer tokens. Add a pre-merge test that feeds a fake key through the logger and asserts the output contains neither the value nor a URL with credentials.
For the issue call, keep the idempotency key stable across a retry. On a 429, honor Retry-After with capped exponential backoff; a second POST without that guard can create two valid keys and make the cutover ambiguous. The same rule applies to revoke operations. A retry is an ordinary network event, not evidence that the first write did not happen.
What does a leaked build log change in the rotation decision?
Assume the log is public the moment a secret is printed. Do not wait for proof of use. Freeze the affected workflow, preserve the run ID, and issue the replacement key with a narrower scope. Then compare request telemetry for the old key against the log's time window and tenant set.
A useful incident record has four independent identifiers: workflow run, deployment revision, key ID, and billing account. The key ID is safe to log; the secret value is not. If your provider cannot expose a non-secret key identifier, hash a locally stored identifier and keep the mapping in the incident system.
I once expected a one-line secret mask to cover a multiline JSON response. It did not. The response had a newline between the prefix and token, so the redaction rule matched neither half. The lesson was concrete: test the exact serialized shape produced by the logger, including escaped newlines and error objects with nested cause fields.
Three minutes of testing beats an afternoon of searching archived logs.
Rotation should be a state machine, not a single button:
-
prepared: new key exists, scoped to the same buildings and actions. -
canary: one deployment uses it and records its key ID. -
active: all production instances accept the new key. -
retiring: old-key requests are zero, or explicitly explained. -
revoked: old key is invalid and the incident record is complete.
If a canary fails, leave the old key active and roll back the reference, then investigate. That rollback path is why overlapping keys matter.
Picture a Tuesday release for 38 buildings. The rotation job creates key k_204, stores it under a new secret version, and deploys one worker with that version. The worker emits workflow=rotate, revision=8f2c, buildingId=building-017, and keyId=k_204; it never emits the credential. After ten minutes, dashboards show successful lease reads and no work-order write failures for the canary. The deployment then updates the remaining workers in two batches. During the first batch, a stale process still uses k_203, so the dashboard shows three old-key requests. That is a useful signal, not a reason to revoke early: identify the process, drain it, and repeat the observation window. Once the old-key count reaches zero and the billing export attributes calls to the intended service identity, revoke k_203. If the canary had failed, the same records would point to one revision and one building, making rollback and charge correction precise. This is the difference between “we changed a secret” and a rotation you can explain to an auditor.
Which controls make billing attribution trustworthy?
Least privilege answers “what can this key do?” Attribution answers “who should pay for that action?” They are related, but they are not the same field. Give each pipeline or tenant group a stable service identity, and attach that identity to every request's structured context.
For a property platform, the context might include portfolioId, buildingId, workflow, revision, and keyId. Keep tenant IDs out of free-form message strings where a log shipper can split or redact them incorrectly. Emit a JSON event instead, with a schema checked in alongside the service.
Metrics catch a different class of mistake. Track request count and error rate by key ID, action, and building. An alert on “old key used after retirement” is more useful than an alert on total 401 responses, because a spike in 401s can be a normal deploy typo while old-key traffic is a rotation signal.
The accounting export should be reproducible: a reviewer can select a billing period, filter the service identity, and reach the same total without reading raw secrets. If two jobs share one identity for convenience, label that limitation in the bill. Do not imply tenant-level precision you cannot measure.
Limits and decision points
The catch is that scoped keys cannot repair a platform with coarse permissions. If the only available scope is “all production data,” use a broker or a separate account boundary and record that the permission is broader than the job requires. If the provider cannot overlap keys, choose a short maintenance window and publish it to the on-call rotation.
This approach is not suitable when CI must call an interactive, user-bound API; use workload identity or a broker that exchanges the job identity for a short-lived token. It is also a poor fit for untrusted pull requests that can modify workflow definitions. Keep production rotation in a protected environment with required reviewers.
Your mileage may vary on retention. I’m not sure a 24-hour quiet period is right for every portfolio; request volume, lease deadlines, and incident policy should set it. The decision rule is stable, though: choose the simplest mechanism that gives you narrow permissions, a visible key ID, a tested rollback, and an auditable revoke.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.