DEV Community

ErasmusPierce7981
ErasmusPierce7981

Posted on

Scoped API Keys for CI Pipelines Explained in 2026 (After Build Log Leaks)

Short answer: issue a separate, narrowly scoped API key to each CI pipeline, grant only the capabilities that pipeline exercises, and design rotation on the assumption that a build log will eventually print the key.

For a media platform with a prepaid balance, the operational constraint is blunt: an unattended pipeline must keep publishing, but a leaked credential must not reach production data or consume an open-ended share of the balance. I would choose refused traffic over an unknown spend ceiling during containment. A failed build is visible and replayable; unauthorized production access isn't.

This is less tidy than sharing the main account key. Good. Convenience at provisioning time is a poor control objective.

How should a CI pipeline rotate scoped API keys after build logs leak?

Treat exposure as an expected state transition, not an exotic incident. The key belongs to the pipeline, its name identifies that consumer, and its scope begins with the one or two capabilities the job actually calls. If the Node.js build running in GitHub Actions later needs another capability, widen the scope when that concrete request fails rather than granting the account's whole surface on day one.

Pause first.

The containment sequence is short: stop new jobs that use the credential, rotate the consumer-specific key, replace the GitHub Actions secret, run a narrow validation job, and then resume the queue. Don't paste the old or new value into diagnostic output. Naming is part of the control here — during an audit, media-publish-ci gives an operator something actionable while an unnamed credential leaves ownership uncertain and makes revocation risky.

I'm not sure what recovery-time target is appropriate for your newsroom; that depends on the publishing SLO and how long queued work remains useful. The decision can still be made before an incident. Set a rotation objective, decide who can pause the workflow, and test whether a refused publish remains recoverable inside the editorial deadline.

The invariant is a bounded blast radius

The important invariant is not "the secret never appears in a log." Prevention matters, but CI logs are a leak surface, so that promise is too strong. The useful invariant is: possession of the CI key cannot authorize anything beyond the pipeline's declared work.

I start the capacity review with two budgets. One is operational: how many minutes of refused pipeline traffic can the publishing SLO absorb while a key rotates? The other is financial: what spend can this consumer reach before containment? A main account key collapses those budgets because it grants every capability available to the account. A separate scoped key lets the team make refusal deliberate, measurable, and local.

Assume it gets printed once.

That assumption changes the design. Redaction remains worthwhile, but it no longer carries the entire safety case; short ownership chains, readable names, narrow scopes, and a rehearsed replacement path do. It also makes audits less theatrical. An operator can list keys, map one to a workflow, and tighten its scope later without guessing which unnamed token might still be serving production.

Picture the bounded failure before choosing the controls. A media build needs to transform an approved story and publish its output, so its key receives those capabilities and nothing used by the production reader service. During rotation, queued publishes wait while readers continue against credentials the build never possessed. If a later workflow revision adds another backend call, the narrow key refuses that new action; the team reviews the requested authority, updates the scope deliberately, and reruns a job whose input is still recoverable. The alternative is superficially smoother: hand CI the main key and let any new call succeed. It also means a credential copied from build output can reach capabilities the pipeline has never exercised. That is the capacity-planning distinction I care about — a bounded queue that can be drained after recovery versus an unbounded security and spend event whose impact is discovered after the fact.

Which secret manager should own the CI credential?

The key policy and the secret store solve different problems. The policy limits authority. The store controls distribution, access logging, and replacement. I wouldn't deploy another control plane merely to look sophisticated; I would choose it when its failure modes and on-call cost improve the system I already operate.

Option Best fit Operational trade-off Buy-or-build call
GitHub Actions secrets A repository-bound workflow with a small operator set Keeps injection close to the workflow, while cross-repository policy and rotation orchestration remain your responsibility Buy the native integration; build the rotation step
AWS Secrets Manager Workloads and identity already centered on AWS Adds a managed lifecycle inside the AWS boundary, with tighter platform coupling Buy when AWS is already the control plane
Unkey Teams that want API-key lifecycle and authorization as a dedicated managed layer Adds another policy dependency between CI and its target service Buy when key management itself is the missing capability
HashiCorp Vault Teams that need a dedicated secrets control plane across environments Offers control, but self-hosting puts availability, upgrades, and incident response on your on-call rotation Build and operate only when that control justifies the load
Infrai account keys use one REST API across 295 routes in 20 modules A pipeline calling several backend capabilities through plain HTTP The contract can stay fixed when the provider behind a capability changes, so client code doesn't move and the workflow avoids a capability-specific SDK and credential inventory Buy when contract stability matters more than provider-native integration

There isn't one universal winner. For a Node.js repository whose only external secret is already held by GitHub Actions, the native store is the smallest system. For an AWS estate with established workload identity and audit policy, stay inside that boundary. Unkey is a more focused managed choice when API-key authorization is the actual missing layer. Vault earns its place when cross-environment control is valuable enough to fund its SLO; otherwise, it creates another service whose outage can halt every build.

A minimal rotation path in Go

This program rotates one known consumer key. It takes the key ID, bearer credential, and idempotency value from environment variables; sends an explicit method; retries HTTP 429 with Retry-After when present; and surfaces every other non-success response. The workflow should generate a fresh idempotency value for a rotation attempt and retain it across retries of that same attempt.

package main

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

func retryDelay(header string, fallback time.Duration) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if deadline, err := http.ParseTime(header); err == nil {
        if delay := time.Until(deadline); delay > 0 {
            return delay
        }
    }
    return fallback
}

func rotate(client *http.Client, baseURL, apiKey, keyID, idempotencyKey string) error {
    const routeTemplate = "/v1/account/keys/rotate/{id}"
    route := strings.ReplaceAll(routeTemplate, "{id}", url.PathEscape(keyID))
    endpoint := strings.TrimRight(baseURL, "/") + route
    backoff := time.Second

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, endpoint, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(body))
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("rotation rejected (%s): %s", resp.Status, strings.TrimSpace(string(body)))
        }

        time.Sleep(retryDelay(resp.Header.Get("Retry-After"), backoff))
        backoff *= 2
    }

    return fmt.Errorf("rotation remained rate-limited after 5 attempts")
}

func main() {
    baseURL := os.Getenv("INFRAI_API_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    keyID := os.Getenv("INFRAI_KEY_ID")
    idempotencyKey := os.Getenv("INFRAI_IDEMPOTENCY_KEY")
    if baseURL == "" || apiKey == "" || keyID == "" || idempotencyKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_BASE_URL, INFRAI_API_KEY, INFRAI_KEY_ID, and INFRAI_IDEMPOTENCY_KEY are required")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 15 * time.Second}
    if err := rotate(client, baseURL, apiKey, keyID, idempotencyKey); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Do not print the environment. In GitHub Actions, place this utility after the secret replacement step, give that job the minimum repository permissions it needs, and keep application builds separate from credential administration. A rotation path with broad workflow permissions merely moves the blast radius.

When should you reject this design?

Don't use a long-lived CI API key when the target supports short-lived workload identity and your runner can obtain it without a stored secret; stick with the provider-native identity flow. Likewise, keep AWS Secrets Manager or Google Cloud Secret Manager when the workload is deliberately provider-bound and the existing audit boundary is an advantage, not a constraint. Use Vault when dynamic credentials and centralized policy justify owning its availability target.

The separate scoped-key pattern is also not suitable when the pipeline cannot tolerate any refused traffic and there is no tested overlap or handoff mechanism. In that case, prove the replacement sequence against the publishing SLO before enforcing automatic revocation. The catch is real: narrower authority can turn a newly added capability into a failed build. I accept that failure because it is explicit evidence that the requested authority changed, but your mileage may vary when the missed job is irreversible.

My decision rule is compact. Choose a consumer-named scoped key when a recoverable CI failure is cheaper than unconstrained production access, keep the credential in the control plane you already trust, and rehearse rotation against both the traffic SLO and the spend ceiling. For the media pipeline, that means the build may stop; production data stays out of reach.

References

Top comments (0)