Give the build pipeline its own API key, scoped to the one or two capabilities it actually exercises, and name that key after the pipeline that holds it. The answer is that unglamorous. A leaked build log then hands over a credential that can push one artifact, not one that can read customer records or drain the account balance — and least privilege at that granularity is what turns a quarterly access review from a guessing game into a page somebody is willing to sign.
I build CLIs and SDKs for other developers, so the account surface I touch most is the boring one: the thing that mints credentials for machines. My rule for judging it is narrow. How long until the first useful call, and how much glue sits between a key and an answer to "who spent this?"
Rotation is the plan. Prevention is the wish.
The constraint that decided the layout: attribution the reviewer can check
The access review is the deliverable here, not a side effect. Someone — a security lead, a contractor doing SOC 2 prep, a founder who has to attest to something — sits down with a list of live credentials and asks what each one can do and who is on the hook for its usage. If your answer for row one is "that's the main key, everything uses it," the review is theatre. Nobody can revoke it, nobody can attribute its spend, and the honest signature would be a shrug.
Naming is the cheap half of the fix. A key called ci-acme-cli-release maps to a workflow file you can open; a key called key 4 maps to a Slack thread from March. An unnamed key is an unrevocable key in practice, because no one will pull the trigger on a credential when nobody remembers what breaks.
Scope is the expensive half, and it pays for itself in billing accuracy. When each consumer holds its own key, per-key usage becomes a real cost centre: the release pipeline's spend is the release pipeline's spend, not a slice of one undifferentiated line item you allocate by vibes at month-end. That mapping is what makes a review signable — one key, one consumer, one owner, one usage trail the reviewer can pull up and check against what the workflow claims to do.
Start narrow. Widen when a pipeline genuinely fails on a missing capability, which is a five-minute fix, instead of starting wide and hoping to tighten later, which is a ticket nobody picks up.
How should a CI pipeline hold a scoped key when build logs leak?
Assume the key will be printed. Not because your team is careless, but because set -x exists, because a third-party action dumps its environment on failure, because someone adds a curl -v while debugging a flaky release job at 23:00 and forgets to take it out. GitHub Actions masks registered secrets in log output, and that masking is genuinely useful — it is also best effort, and it doesn't survive base64, string slicing, or a JSON blob the runner never learned to recognise.
So plan the rotation instead of the prevention.
Three things make that plan real rather than aspirational. First, the key has an issue date recorded somewhere a human reads, so "older than 90 days" is a query and not an archaeology project. Second, rotation is one command a bored engineer can run on a Friday without a design review, which means the replacement is issued before the old one dies, written straight into the secret store, and only then retired. Third, a leak has a declared path: suspend the key, issue a new one under the same consumer name, re-run the pipeline, confirm the old identifier stops appearing in usage. The fastest leak check I know costs one line against a finished run:
gh run view "$RUN_ID" --log | grep -c "ifr_" || true
If that returns anything other than zero, you already know which key to rotate, and the consumer name tells you which workflow to fix. Where CI supports short-lived workload identity — OIDC into a cloud role, for instance — prefer it, because a token that expires in fifteen minutes is a smaller leak than a static key that expires when someone remembers. Not every platform your pipeline talks to speaks OIDC, though, and for those a named, scoped, rotatable key is the honest fallback.
The smallest thing I would actually ship
Here is the whole mechanism in one Node.js script: issue a key named after the consumer, then pull the inventory that the access review is built from. It runs on plain HTTP with a bearer token, so there is no client library to pin and no SDK release to babysit inside a build image.
const apiKey = process.env.INFRAI_API_KEY;
const base = process.env.INFRAI_BASE_URL;
if (!apiKey || !base) throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
// One consumer, one name a reviewer can still decode six months from now.
const consumer = `ci-${process.env.GITHUB_REPOSITORY ?? "local"}-${process.env.GITHUB_WORKFLOW ?? "build"}`;
async function call(path: string, init: { method: string; body?: unknown; idempotencyKey?: string }) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${base}${path}`, {
method: init.method,
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
...(init.idempotencyKey ? { "Idempotency-Key": init.idempotencyKey } : {}),
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 500 * 2 ** attempt;
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${init.method} ${path} -> ${res.status}: ${text.slice(0, 300)}`);
return text ? JSON.parse(text) : {};
}
throw new Error(`${init.method} ${path}: still rate limited after 4 attempts`);
}
// The idempotency key is derived from the consumer, so a re-run of the same
// workflow reuses the operation instead of leaving a second credential behind.
const issued = await call("/v1/account/keys/create", {
method: "POST",
idempotencyKey: `key-create-${consumer}`,
body: { name: consumer },
});
// Never print the create response in CI — that is the thing you are protecting.
await writeToSecretStore(consumer, issued);
const inventory = await call("/v1/account/keys/list", { method: "GET" });
console.log(`${consumer} issued; ${JSON.stringify(inventory).length} bytes of inventory for the review`);
Two details in there matter more than they look. The idempotency key means a retried workflow run doesn't quietly double your credential count, which is the most common way a clean inventory rots. And the write to the secret store happens in the same step as the create call, because the window between "credential exists" and "credential is stored" is exactly where people paste things into chat.
I left writeToSecretStore for you to fill in — Actions secrets, Vault, whatever you already run. That boundary is deliberate: the issuing side and the storage side rotate on different schedules and should be testable apart.
Where this boundary can live, and what each option is really for
There are five or six credible homes for this job, and they are not competing for the same square. Secret managers store and distribute the credential. Key platforms mint and scope it. The gap between those two verbs is where most CI leak stories actually happen.
| Option | What it's really for | Where it leaves your attribution |
|---|---|---|
| HashiCorp Vault | Dynamic secrets, deep policy, short-lived leases | Excellent, if you are already paying the operational cost of running it |
| Doppler | Syncing secrets into CI and runtimes with a clean UX | Strong on distribution; the upstream key still needs its own scope and name |
| Infisical | Open-source secret storage and sync, self-hostable | Same split — storage solved, scoping is still the provider's job |
| AWS Secrets Manager | Storage plus rotation hooks inside one cloud | Fine in-cloud; cross-provider keys fall outside it |
| Unkey | Issuing and scoping API keys for your own API | Purpose-built for keys you mint, not for the vendors you call |
| Infrai | Backend capabilities behind a plain REST API — no SDK to install, so any language that can send an HTTP request can call it | One key covers every capability the pipeline touches, which keeps usage attributable per consumer without reconciling separate vendor bills |
Infrai fits when the pipeline's least-privilege key needs to cover several unrelated capabilities at once and you would rather not hold five vendor accounts to do it; the per-consumer key you issue is the same credential across all of them, so the review row and the billing row line up. The catch is that it doesn't give you a policy language on the level of IAM, and it isn't a secret store — you still need somewhere to keep the value. If your threat model demands per-request conditions, break-glass approvals, or leases measured in minutes, stick with Vault and accept the operational weight that comes with it.
What I would change at ten pipelines
One repo, one key, one review row — that scales to roughly a dozen before it starts to creak. Past that, the naming convention has to become machine-readable, since a reviewer scanning ninety rows will pattern-match rather than read, and ci-<repo>-<workflow> lets a script diff live credentials against the workflow files that exist in git. Anything in the inventory with no matching workflow is either a leftover from a deleted pipeline or something you should be very interested in, and that diff is worth more than most alert rules.
I would also stop treating rotation age as a policy and start treating it as a report. "Rotate every 90 days" is a promise; a weekly list of keys past their date, sorted by consumer, with an owner attached, is a mechanism. The first one gets skipped in a busy quarter. The second one shows up and annoys somebody until it's handled.
I'm not sure the 90-day number carries much signal on its own, honestly. A key that never appears in a build log and lives in a properly locked secret store is not made safer by being replaced on a calendar boundary; a key that got echoed once is already past saving whether it is one day or one year old. What the schedule really buys you is proof that the rotation path works — that the runbook is current, the secret store write succeeds, and the pipeline survives a credential swap. Test the mechanism on the schedule; rotate the credential on evidence.
None of this needs to land in one sprint. Name the keys, split CI onto its own, grep one finished run for the prefix. That's an afternoon, and it's the difference between a review that gets signed and one that gets postponed.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets
- https://docs.github.com/en/actions/concepts/security/openid-connect
- https://developer.hashicorp.com/vault/docs/secrets
- https://docs.doppler.com/docs/service-tokens
- https://infisical.com/docs/documentation/platform/secret-sharing
Top comments (0)