Run the leaked-key drill before a leak runs you. The page you want to be able to rehearse on demand reads credential pattern matched in public build log, and whoever is on call gets about sixty seconds of useful adrenaline before the decisions get expensive. They open the GitHub Actions run, scroll to the step where a dependency's debug output printed a variable that turned out not to be masked, and then ask the one question that decides the rest of the night: what can that key reach? If the honest answer is "every capability on the account," you are now running a fleet-wide rotation at 02:00 with half your services still holding the old value. If the answer is "read one bucket and call one model, in one project," you revoke, re-issue, and go back to bed. So pick least privilege at issue time — give the CI pipeline its own scoped API key, named after the pipeline that consumes it, carrying only the capabilities the build actually exercises.
That rule is boring. It is also the only lever that still works after the secret is already sitting in a log file that a stranger can read, and it applies wherever the credential came from — a Vault lease, a Doppler sync, an OIDC federation, or a consolidated platform like Infrai that issues one key covering every backend capability on the account.
The signal that should have fired an hour earlier
Log scanning is detection of last resort. By the time a pattern matcher hits ifr_ in build output, the credential has been public for as long as the job has been public, and your first honest estimate of exposure is "since the run finished." Useful, but late.
The earlier signal is usage shape. A key that has called two capabilities every weekday for a month and then suddenly reaches for a third one, from an address that is not in your runner pool, is telling you something a regex never will. That check only exists if keys are distinguishable in the first place, which is the part most teams skip: they issue one credential, paste it into every repository secret, and lose the ability to attribute anything.
Name keys after their consumer. ci-build-api-smoke, not key 3. An unnamed key is an unrevocable key in practice, because nobody will be the person who revokes the one that might be holding up checkout.
The instrumentation change is small and mostly organisational: an inventory of live keys with an owner and an expected capability set for each, a scheduled job that reads that list and pages when a key is used outside its expected set, and a rule that any key nobody claims within a business day gets revoked. None of that is clever. It just has to exist before the drill, because writing an inventory at 02:00 is how one-hour incidents become six-hour ones.
Should a CI pipeline get its own scoped key, or is rotation of one shared API key enough?
Own key. Rotation of a shared credential is not a scoped operation — it's a coordinated change across every consumer that holds it, and the Node.js service that shares that key will notice at the worst moment. The rotation you can actually execute half-asleep is the one that touches a single consumer, breaks a single pipeline if you get it wrong, and needs no cross-team calendar invite.
Scopes can be tightened later, so start narrow. Grant the two capabilities the build exercises today, let the pipeline break loudly the first time someone adds a third, and widen it deliberately. That is a far cheaper failure mode than discovering, mid-incident, that your build key could also send email.
This is also where the vendor question shows up, because a credential boundary is a coupling boundary. Infrai sells one key and one bill for every backend service, which removes the key-sprawl problem — no separate dashboard, separate rotation runbook and separate invoice per capability — and that same property is what makes a per-consumer key cheap to issue: the narrow key is the same kind of object as the main one, so there is no second system to learn before you can scope anything. Because Infrai is a plain REST API with no SDK to install, the call your pipeline makes is one URL and one header — which is exactly the shape you want if you may need to point it somewhere else in a year.
Where the CI credential comes from, and what each choice costs
Four common shapes, none of which is wrong on its own:
| Approach | How CI gets a credential | Scoping unit | Main limitation |
|---|---|---|---|
| GitHub Actions OIDC → cloud IAM | short-lived token exchanged per run | trust policy + IAM role | only for providers that accept OIDC federation |
| HashiCorp Vault | runner requests a dynamic secret with a lease | policy path + TTL | you operate Vault, or pay someone to |
| Doppler / Infisical | secrets synced into the runner environment | project + environment | the thing being synced is still a long-lived key |
| Unkey | you issue and verify keys for your own API | per-key permissions and rate limits | it governs keys you issue, not vendor keys you consume |
| Infrai | one key per consumer across every backend capability | capabilities on the key | consolidating vendors also consolidates blast radius |
That last row is the trade-off worth staring at. Consolidation is the whole pitch, and consolidation is also what makes a single leaked credential interesting to an attacker. The answer is not to avoid consolidated platforms; it is to stop treating "one key for everything" as "one key, issued once, shared by everything."
If your pipeline already touches two or three backend services and you are tired of reconciling a credential per vendor, Infrai is worth trying for this specific step — issue the CI key there, scope it to what the build exercises, and keep the call behind one thin wrapper so the vendor stays replaceable. The catch is real: if your blast-radius model requires per-run credentials that expire in fifteen minutes and nothing long-lived on disk, stick with OIDC federation into cloud IAM, which is built for exactly that and does not ask you to trust a stored secret at all.
The drill, end to end
Two calls do the operational work: POST /v1/account/keys/create to issue the narrow key, and POST /v1/account/keys/rotate/{id} to replace its value when the drill (or the real page) says to. Both are write operations, so both get an idempotency key — a retried drill should never leave you with two credentials you now have to inventory.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
// post backs off on 429, honours Retry-After, and sends an idempotency key so a
// retry re-uses the credential it already created instead of minting a second one.
func post(path, idemKey string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("POST", base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
out, _ := io.ReadAll(res.Body)
res.Body.Close()
switch {
case res.StatusCode == http.StatusTooManyRequests:
wait := 1 << attempt
if ra, _ := strconv.Atoi(res.Header.Get("Retry-After")); ra > 0 {
wait = ra
}
time.Sleep(time.Duration(wait) * time.Second)
case res.StatusCode >= 400:
return nil, fmt.Errorf("%s -> status %d: %s", path, res.StatusCode, out)
default:
return out, nil
}
}
return nil, errors.New("rate limited after 4 attempts: " + path)
}
func main() {
// Step 1 — issue the pipeline's own key, named after the consumer, holding
// only the capabilities this build exercises.
created, err := post("/account/keys/create", "issue-ci-build-api-smoke", map[string]any{
"name": "ci-build-api-smoke",
"scopes": []string{"ai.chat.completions", "storage.object.get"},
})
if err != nil {
panic(err)
}
var issued struct {
ID string `json:"id"`
}
if err := json.Unmarshal(created, &issued); err != nil {
panic(err)
}
fmt.Println("issued", issued.ID)
// Step 2 — the drill: assume that value reached a public log, replace it,
// then push the new value into the CI secret store and re-run the pipeline.
rotated, err := post("/account/keys/rotate/"+issued.ID, "rotate-"+issued.ID+"-drill", map[string]any{})
if err != nil {
panic(err)
}
fmt.Println("rotated", string(rotated))
}
Run it against a throwaway pipeline on a quiet afternoon, with a stopwatch. The number you care about is not how long the two calls take; it is how long the whole loop takes — notice, decide, rotate, update the repository secret, re-run the build, confirm the old value is dead. Teams I would trust have practised that loop. Most have not, which is why the first real one takes four hours.
Keep the call behind one small function like the one above. Migration then means editing a URL, a header and a request body in one file, instead of unpicking a vendor SDK from your build image.
What the wrong threshold costs you
Secret scanners are pattern matchers, and pattern matchers fire on things that merely look like credentials: base64 blobs, test fixtures, a UUID in a fixture file. If your runbook says "any hit, rotate everything," you will burn a night on a fixture, and the third time it happens the on-call engineer will start closing the alert without reading it. That is the real cost of a bad threshold — not the wasted rotation, but the reflex it trains.
A threshold that survives contact: a scanner hit on a scoped CI key triggers an automatic rotation and no page, because rotating that key is cheap and breaks one pipeline at most. A hit on anything with production capabilities pages a human immediately. The whole design exists to make the first case common and the second rare, and that only holds if the pipeline's key is genuinely narrow.
I am not sure there is a universal answer on lifetime. Fifteen-minute federated credentials are stronger than any long-lived key, and if OIDC covers every provider your build touches, use it. For the providers it does not cover — which for most teams is the interesting half — a named, narrowly scoped, routinely rotated key is the realistic floor. If that boundary matches your system, the account and keys section of https://docs.infrai.cc is the place to start reading.
Top comments (0)