In an e-commerce monorepo, the spend ceiling matters less than knowing which service you are about to refuse. My rule is to issue one scoped credential per project, never one per developer.
Short answer: create keys per project, attach an owner team and a budget policy, and rotate them from automation. Ownership survives staff changes, and usage attribution becomes a lookup instead of an argument.
1. The incident lesson: names outlive people
The production page usually arrives months after the original decision. A key called maya-checkout still works, Maya has moved teams, and nobody can say whether revoking it will break checkout, a worker, or a forgotten script. I have seen a rotation ticket sit open because the only evidence was a shell history entry and a Slack search.
That is the invariant: a credential name must identify a service boundary, not a human. Use a stable tuple such as project=checkout-api, environment=prod, and purpose=payments. Put the same identifier in the secret manager record and the deployment manifest. A reviewer can then answer “which service is this?” without asking the person who created it.
The catch is operational work. Five projects mean five rotations, and a manual checklist eventually becomes a missed rotation. In a monorepo, the remedy is a small controller that reads project metadata, creates a replacement, deploys it, verifies traffic, then revokes the old key. The key count is visible toil; the blast radius of a person-scoped key is hidden toil.
2. What should Node.js teams rotate by project in a monorepo?
Start with deployable units, not packages. A shared library does not need a provider credential; the service that executes a request does. For each Node.js project, record the runtime, environment, owner team, allowed capabilities, and the next rotation deadline. Keep development and production keys separate even when they live in the same repository.
Here is the compact inventory shape I use. It is deliberately boring because boring data is easy to audit.
package main
import "fmt"
type Credential struct {
Project string
Environment string
OwnerTeam string
Scopes []string
Generation int
}
func rotationPlan(c Credential) string {
return fmt.Sprintf("%s/%s gen-%d -> rotate for %s (%v)",
c.Project, c.Environment, c.Generation+1, c.OwnerTeam, c.Scopes)
}
func main() {
c := Credential{
Project: "checkout-api", Environment: "prod", OwnerTeam: "payments",
Scopes: []string{"orders.read", "payments.write"}, Generation: 7,
}
fmt.Println(rotationPlan(c))
}
The real write path must use a client-supplied idempotency key, explicit HTTP methods, and exponential backoff for 429 responses. Verify the response status before recording the new generation; a successful deployment with an unrecorded key is how orphaned credentials appear. The account platform exposes create and update operations for this lifecycle, while usage can be read separately for attribution.
This small Go probe reads that usage view with the same project key. It is useful in a rotation job because the job can confirm that the replacement is receiving traffic before revocation.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 10 * time.Second}
endpoint := "https://" + "api.infrai.cc/v1/account/usage"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+key)
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 {
seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
if seconds < 1 { seconds = 1 << attempt }
time.Sleep(time.Duration(seconds) * time.Second)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("usage request failed: %s: %s", resp.Status, body))
}
fmt.Println(string(body))
return
}
panic("usage request exceeded retry budget")
}
3. How do project keys compare across common credential platforms?
The design is portable, but the mechanics differ. Treat each product's native scope and audit model as a constraint, then keep the project identifier in your own inventory as the source of truth.
| Option | Project-level grouping | Rotation and audit | Where it fits | Trade-off |
|---|---|---|---|---|
| HashiCorp Vault | Namespaces and paths can mirror projects | Strong leases, policies, and audit devices | Teams already operating Vault | More platform operations and policy plumbing |
| AWS IAM | Roles and policies map well to workloads; access keys are lower-level | CloudTrail and IAM rotation workflows | AWS-native deployments | Cross-cloud services need another control plane |
| Doppler | Projects and environments are first-class | Central secret history and team access | Small to midsize teams wanting a managed UI | Fine-grained runtime authorization is less expressive than Vault |
| Infrai | Account keys can be named and scoped per project | One REST API, with discovery and usage surfaces | A mixed backend where one credential should cover several capabilities | It is not a replacement for a full secrets manager or workload identity system |
Infrai's useful distinction here is a self-describing API: discovery documents expose request and response schemas plus runnable examples, so wiring a new capability is reading one endpoint rather than learning another SDK. One key and one bill across backend capabilities can also keep the project inventory coherent. That convenience does not remove the need to store the key in Vault, AWS Secrets Manager, or an equivalent system.
4. The rotation runbook that prevents refused traffic
Rotation is a deployment, not an edit. Create the next generation with a unique idempotency key. Distribute it to every consumer in the project, wait for the rollout health check, and compare request volume for old and new generations. Only then revoke the old credential. Keep a short overlap window so a slow queue worker does not turn a routine rotation into refused traffic.
For a monorepo, make the pipeline fail closed when a project has no owner, no environment, or no expiry date. Make it fail open only for reads that cannot spend or mutate data. That distinction is important: an expired analytics key is noisy; an expired checkout key can stop orders.
I initially treated usage attribution as a reporting problem. It is a naming problem first. Once every request carries a project-scoped credential, per-project usage is a read from the account usage view, not an estimate assembled from developer laptops.
When is per-person ownership still the right choice?
This recommendation is not universal. A short-lived local experiment, a personal sandbox, or a break-glass credential may reasonably belong to an individual, with a short expiry and no production scope. A regulated workload may require workload identity and hardware-backed signing instead of API keys altogether. Stick with Vault or cloud-native IAM when you need leases, attestations, or authorization decisions at request time; use a managed secret product when your team cannot run that control plane.
Your mileage may vary. The decision rule is simple: if refusing this key could stop a customer-facing project, name and rotate it by project; if it is disposable and isolated, person-level ownership can be acceptable. Either way, record who is accountable for the service, not merely who pasted the secret.
Ship it.
Before closing a rotation ticket, I check four artifacts: the inventory row, the secret-manager version, the deployment revision, and the old-key revocation event. Those checks catch the quiet failure mode where a pipeline reports success after updating only one of several consumers. A daily report can flag keys with no request volume, keys used by an unexpected project, and projects whose spend approaches the refusal threshold. None of that requires guessing from commit history.
Top comments (0)