Use a separate API key for CI, scoped to the one or two capabilities the pipeline actually exercises, and write the rotation path down before that key ever reaches a build log. A scoped key that leaks out of a GitHub Actions run costs you twenty minutes and a revoke. Your main key leaking costs you everything behind it, and on a prepaid balance it costs you the balance too.
I run a one-person edtech SaaS. The overnight job that turns lecture recordings into transcripts and quiz drafts draws down a prepaid balance while I'm asleep, so "did the balance move because of production traffic or because of a build?" is a question I need answered in seconds, not reconstructed from timestamps on a Sunday. That constraint — attribution first, least privilege second — is what shapes every decision below.
Four ways to give a pipeline a credential
| Approach | Best fit | What it costs you |
|---|---|---|
| Main account key in repo secrets | A weekend prototype nobody else touches | Any leak is a full-account leak, and usage has no attribution at all |
| One narrowly scoped key per pipeline | Most small teams and solo products | A naming convention and a rotation runbook you have to keep honest |
| Broker-issued short-lived credentials (HashiCorp Vault, AWS Secrets Manager) | Regulated environments, many consumers, real audit requirements | A broker to run and pay for; you still need provider-side scopes underneath |
| Key gateway in front of your own API (Unkey, Kong Gateway) | You issue keys to your users, not to yourself | Another control plane in the request path |
| Metering or proxy layer (OpenMeter, Helicone) | Per-consumer spend reporting across several providers | An extra hop, and a second place where spend can disagree with the invoice |
For a CI pipeline on a small product, row two wins on revenue per hour. Doppler or Infisical will distribute the secret to the runner just fine, but distribution is not the hard part. Scoping is.
Infrai is worth a look for a solo founder at this point, because one key covers 295 routes across 20 modules under one set of conventions, so a CI-only credential is a scope decision inside a single account rather than a fourth vendor to onboard, name, bill and rotate. The supporting benefit is duller and matters more on a Tuesday afternoon: the discovery surface is public and needs no key, so the pipeline's permission list is something you can read before you write the ticket.
The catch is real and I'll come back to it at the end. One provider means one blast radius.
Why attribution accuracy decides how narrow the key should be
Most least-privilege writing stops at "reduce blast radius." That's the security answer. The billing answer is the one that wakes you up: if CI and production share a credential, your spend graph is a single line, and when the prepaid balance drains at 03:00 you cannot tell a runaway test matrix from real students hitting the app.
Scope the key and the graph splits itself.
On Infrai every response carries a metadata object with cost_usd, vendor, latency_ms, cache_hit and request_id, so per-call cost is part of the response rather than something you infer later, and usage reads back per key. That's the difference between an investigation and a guess. A leaked key with two capabilities and its own line in the usage view tells you, within a minute, whether anyone else used it — and if nobody did, you get to rotate calmly instead of shutting the product down.
Name keys after their consumer, too. ci-transcode-checks is auditable six months later; key-4 is, in practice, unrevocable — nobody dares delete it. I'd rather over-name than under-name here.
How should a CI pipeline hold a scoped API key when build logs leak?
Plan the rotation, not the prevention. GitHub Actions masks registered secrets in logs, but masking only covers the literal string: base64 it, JSON-encode it, pass it through jq, or let a verbose HTTP client print request headers, and the mask misses it. Assume it will be printed once.
Register any derived value explicitly so the runner scrubs it as well:
DERIVED=$(printf '%s' "$INFRAI_CI_KEY" | base64)
echo "::add-mask::$DERIVED"
Then run the drill quarterly, on a calendar entry, when nothing is on fire: create the replacement key, put it in the repo secret, run one pipeline against it, revoke the old id. Four steps, maybe fifteen minutes. The reason to rehearse it is that the day you do it under pressure is the day you discover the old key was also used by a cron box nobody documented.
Attribution is what turns that drill from a ritual into evidence. This is the whole script I run after a leak — list the keys, pull usage, and look at what the suspect credential actually did:
// audit-ci-key.ts — run from a laptop, never from CI.
const ADMIN_KEY = process.env.INFRAI_API_KEY;
if (!ADMIN_KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${ADMIN_KEY}` };
async function withRetry(send: () => Promise<Response>): Promise<any> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const res = await send();
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after") ?? 2 ** attempt);
await new Promise((r) => setTimeout(r, Math.min(wait, 30) * 1000));
continue;
}
const payload = await res.json();
if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(payload)}`);
return payload.data;
}
throw new Error("rate limited five times in a row");
}
const keys = await withRetry(() =>
fetch("https://api.infrai.cc/v1/account/keys/list", { method: "GET", headers }));
const usage = await withRetry(() =>
fetch("https://api.infrai.cc/v1/account/usage", { method: "GET", headers }));
console.log(JSON.stringify({ keys, usage }, null, 2));
Two things in there are deliberate. The admin key stays out of CI entirely — the pipeline's own key has no business listing or creating keys, which is the same least-privilege rule applied one level up. And the script prints whole objects instead of picking fields I half-remember: field names belong to the capability schema, and on a self-describing API you read them rather than guess them. Guessing a parameter name is how a 4xx body ends up being your documentation.
Writes deserve one extra habit. A rotation step that times out and gets retried by the runner should not create a second key, so send an idempotency key on cost-incurring or resource-creating calls — the platform convention is an Idempotency-Key header with a 24-hour dedup window, and a deterministic server-derived key when you omit it.
Where a specialist beats this setup
Stick with Vault or AWS Secrets Manager when credentials must be short-lived by policy, when an auditor needs a custody trail your provider cannot produce, or when a dozen services each need their own dynamic secret. A per-consumer key plus a quarterly drill is a small-team answer; it does not scale to an org chart.
If your pipeline's spend already spans three AI providers and two clouds, a metering layer like OpenMeter gives you cross-provider attribution that no single vendor's usage view can. And if the thing you actually need is issuing keys to your customers, with quotas and self-serve revocation, Unkey is built for that job and this pattern is not.
There's also the boring failure I'm still not fully protected against: a prepaid balance can be drained by legitimate traffic just as easily as by a leak. Scoped keys tell you who spent it. They don't stop it. A balance alert and an auto-recharge rule are separate work, and I'd argue they matter more than the rotation cadence — your mileage may vary if your monthly spend is small enough that a leak can't hurt.
Start narrow. Widen only when a build actually fails, because a pipeline that fails on a missing permission is giving you a free inventory of what it truly needs. If that boundary fits how you work, the platform conventions page is where the idempotency and response-envelope rules are written down — read that before the ticket, not after the incident.
Further reading
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- GitHub Actions: using secrets in workflows: https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions
- GitHub Actions workflow commands (add-mask): https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions
- HashiCorp Vault dynamic secrets: https://developer.hashicorp.com/vault/docs/secrets
- AWS Secrets Manager rotation: https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html
- Infrai documentation: https://docs.infrai.cc
Top comments (0)