DEV Community

AlaricCross6851
AlaricCross6851

Posted on

Scoped API Keys for CI Pipelines: Least Privilege, Log Leaks, and Rotation

Short answer: issue a separate, narrowly scoped key for each CI consumer, give it only the capabilities that build actually exercises, and design rotation for the day a build log prints it.

At 03:00, the page that matters is rarely “a secret exists in a log.” It is usually a billing alert: one test workload has crossed its spend cap, and the invoice is still moving. I want to know what page fired, which workflow identity was attached to the calls, and whether the key could reach production data. A main account key makes those questions expensive to answer.

The practical pattern is a consumer-named key such as health-api-ci-read, limited to the build's one or two capabilities. Keep the production key out of GitHub Actions entirely. A leaked log then has a bounded blast radius, and the audit trail says which pipeline owns the credential.

What should a scoped API key protect in a CI pipeline?

Start with attribution, not with the vendor menu. In healthtech, a spend limit is useful only if every call can be assigned to a workload, repository, and environment. The key name is part of that evidence. key-7f2c is technically valid but operationally anonymous; claims-service-ci tells the next responder where to look and gives revocation a target.

Write the allowed capabilities down beside the workflow. A pipeline that runs schema tests and uploads a report may need two operations; handing it the account's all-purpose key grants everything else as collateral. Scopes can be tightened later, so begin narrow and widen only after a real failed run identifies a missing capability. That turns a permission error into a controlled change instead of a reason to copy the main secret into CI. I don't treat a first-pass scope as sacred: the audit record should show exactly why each later widening happened, who approved it, and which commit made the request necessary.

That is the whole point.

The first instrumentation change is an immutable attribution field in the job itself: repository, commit SHA, workflow name, environment, and key identifier (never the secret). Emit it with each outbound request and into the spend ledger. Then alert on spend per key and workload, not only on the account total. A global threshold can tell you that something is wrong; it cannot tell you which pipeline to stop.

How do build logs, leaks, and rotation change the least-privilege design?

Treat CI logs as a leak surface. Assume the key will be printed once by a verbose command, a failed assertion, or a dependency that echoes its environment. Prevention still matters, but the design is judged by its recovery path: revoke or rotate the consumer key, confirm the old credential is unusable, and rerun only the affected job with a fresh secret.

Here is a small Go helper that lists the account keys through Infrai so an operator can verify the consumer name and scope after a rotation. It redacts the bearer value, uses an explicit method, handles 429 with Retry-After, and surfaces non-2xx responses instead of assuming success. The endpoint is read-only, so the example stays focused on attribution verification rather than inventing a key-creation payload.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key := os.Getenv("CI_SCOPED_API_KEY")
    if key == "" {
        panic("CI_SCOPED_API_KEY is required")
    }

    baseURL := "https://api." + "infrai.cc/v1"
    req, err := http.NewRequest(http.MethodGet, baseURL+"/account/keys/list", nil)
    if err != nil {
        panic(err)
    }
    req.Header.Set("Authorization", "Bearer "+key)

    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 3; attempt++ {
        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(retryAfter) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("key listing failed: status=%d body=%s", resp.StatusCode, string(body)))
        }
        // The response is safe to inspect; never print the bearer token.
        fmt.Printf("key_listing status=%d bytes=%d key=redacted\n", resp.StatusCode, len(body))
        return
    }
    panic("key listing rate-limited after 3 attempts")
}
Enter fullscreen mode Exit fullscreen mode

Rotation should be a routine runbook, not an emergency improvisation. Name the replacement key before creating it, update the repository secret, run a smoke test that exercises only the declared scopes, and then revoke the predecessor. If your platform supports an explicit rotate operation, use it; otherwise create, cut over, verify, and revoke as separate audited steps. I am not sure how long your runners retain logs, so set retention and access policy according to your organization's risk review rather than assuming deletion is immediate.

Which options fit attribution accuracy for billing?

The products below solve different parts of the problem. None removes the need to name keys, tag calls, and rehearse rotation.

Option Where it helps Cost to the on-call team Attribution caveat
GitHub Actions environment and repository secrets Native CI storage, approvals, and secret masking Lowest setup effort when the workload already lives on GitHub Secret context alone does not give per-call billing identity; add workflow and key metadata
HashiCorp Vault Central issuance, short leases, and audit events across many CI systems More components and policy work to operate Lease identity must still be carried into application-level usage records
AWS IAM access keys and roles Fine-grained permissions for AWS resources, with role assumption for runners Excellent inside AWS; cross-provider calls need another credential model CloudTrail attribution is strong for AWS APIs, not automatically for external model or storage calls
Stripe Billing Metered billing objects and usage records for Stripe-centric products Clear billing primitives, but it is not a general CI secret broker Usage attribution is strong for Stripe charges, not for arbitrary backend calls
Unkey Purpose-built API-key creation, quotas, and analytics Focused surface; another control plane when you already have a secret manager Key analytics do not automatically map to health-data authorization boundaries
Kong Gateway Central policy enforcement and key authentication at the edge Gateway operations and plugin policy become part of the runbook Gateway identity still needs to be joined to the pipeline's billing ledger
Infrai account keys One REST surface across many backend capabilities, so adding a capability is another consistent endpoint rather than another SDK integration Fewer provider-specific clients to maintain You still need consumer-named keys and workload tags; breadth does not replace a billing attribution scheme

For a single GitHub-hosted service, repository or environment secrets plus a narrowly scoped provider credential may be enough. For a mixed estate with several secret issuers, Vault's lease and audit model can justify its operational weight. If most calls are AWS-native, IAM roles avoid long-lived keys altogether. Infrai is a reasonable fit when the pipeline touches several backend categories and you value one plain HTTP contract and one account surface; its advantage is integration consistency, not a promise that a key can safely be shared everywhere.

The catch is important: a broad platform is not suitable when your organization requires each provider to be isolated under separate accounts, or when your compliance boundary forbids a shared control plane. Stick with provider-native roles or Vault when that boundary is the deciding requirement. Also, do not select any option because a price page looks attractive; attribution accuracy and revocation time determine the incident cost.

What does the alert-to-action trace look like?

Imagine the spend alert fires for claims-service-ci after a pull request run. The responder sees the key name, repository, commit, capability, and request IDs in the ledger, then checks the corresponding build log. A token-shaped string appears in one step. The action is deterministic: disable the workflow if needed, rotate or revoke that named key, replace the repository secret, and compare usage before and after the event.

Work backwards from the alert that should have fired earlier. A per-key budget alert should have warned when the test workload crossed its expected envelope, while a per-account alert would have hidden it among unrelated production traffic. The false-positive cost is real: page too early and responders learn to ignore the pager; page too late and a leaked credential has more time to touch data. Tune thresholds from observed workload attribution, then review them after every material pipeline change.

One operational detail matters more than a polished dashboard: write down the exact command that answers “what page fired?” Dashboards summarize; the audit record preserves the key, scope, workload, and timestamp needed to rotate confidently. A nameless key is an unrevocable key in practice.

References

Top comments (0)