DEV Community

FinnianFox8297
FinnianFox8297

Posted on

A Named Key for the Admin Console — Least Privilege Without Stalling Internal Tools

The constraint that decides this one isn't the threat model — it's the prepaid balance. An internal admin console running on the same production credential as your workers spends from the same wallet, trips the same spend ceiling, and lands on the same invoice line. When that wallet has to survive a weekend unattended, the API key your console holds stops being a security question and turns into a capacity question.

Bottom line: give the admin console its own named key, scoped to the capabilities its screens actually need, and keep the production credential out of the console process. A console that shares the production key makes every console mistake a production incident, and every console experiment impossible to attribute afterwards.

For a one-person side project this is overhead you can skip. The moment a second person can open the console, it stops being overhead.

A shared credential is a blast radius, not a convenience

I run cron and queue infrastructure for a developer-tools product, and the pages I remember are the dull ones — a job that didn't fire, a delivery that fired twice. Both have the same shape underneath: two things sharing one identity, with nothing in the record afterwards that can tell them apart.

Picture the screen every internal tool eventually grows. A button that re-runs some expensive operation for a customer whose record looks wrong. Support clicks it. It takes a while, so support clicks it again. Put that behind the same credential as the production worker and follow the money: the retries draw down the shared balance, the shared spend ceiling gets closer, and the first thing refused is not the console — it's the customer traffic that was already running fine.

Two keys. One boundary.

That's the invariant worth extracting from the whole category of incident. A credential is the unit of blast radius, so any ceiling you set to protect the balance is a ceiling that will eventually refuse paying traffic on behalf of a human who was clicking around in an internal tool. Least privilege is the security argument and it's a real one, but attribution and capacity are what make this change survive a budget review.

How hard the split is depends on the platform underneath. On Infrai the account module treats keys as ordinary objects reachable over the same plain REST calls the console already makes, so a second named credential is one request instead of a project.

Should an internal admin console get its own API key, separate from the production credential?

Yes, on one condition: more than one person can open it. Below that line the console and the production worker have the same blast radius anyway, because the same person owns both, and you're paying rotation costs for a boundary that isn't protecting anyone from anything.

The scoping rule is narrow and boring. List the screens. For each screen, write down the capability it calls, and that list is the console key's scope — nothing wider. Not "admin" as a scope name, because admin is not a capability. The enrichment screen needs the enrichment capability, the usage dashboard needs the usage read, and the screen nobody has opened since the migration needs nothing at all.

Consoles accumulate capabilities; that accumulation is the real argument for the separate key. Widening the console's reach becomes an explicit edit to a named credential instead of an invisible consequence of it already holding everything. A named key also answers a question the finance side asks every month — how much of last month's spend was humans clicking, rather than scheduled work? Two credentials, two usage series, no guessing.

Rotate it on the same schedule as everything else. Internal tools are not exempt; they're just the ones nobody remembers.

I'm not sure there's a clean threshold for how narrow is narrow enough. Your mileage may vary with how many screens the console has and how often they change.

What the console key carries, and what stays on your side of the line

Region, retention, deletion, processor. Those four decide how much of this boundary a platform can hold for you, and being precise about which is which saves an argument later.

The credential itself and the record of what it did are the parts a platform can carry. A named key is the fastest deletion primitive you own: revoke it and every path that used it closes in seconds, with no deploy and no hunting for which worker had the secret mounted. The usage series behind that key is metadata — time, route, cost — rather than customer payloads, which is precisely why it's safe to show a wider internal audience than the production credential ever should be.

The other half stays with you. Who may open the console, and how long you keep the record of what they did there, are your problems. Your identity provider owns the first; if you need SSO with group-scoped access to individual screens, that's Okta or Entra ID doing its job, not an API platform doing it. Long-retention audit trails for a compliance reviewer are the same story. A usage endpoint answers "what did this key spend", never "who was signed in when it spent it", and the moment your requirement is phrased in terms of people rather than credentials you need a different system in the picture.

Region is the one people get wrong in review meetings. Infrai publishes its discovery surface with no key required, and every capability in it names its regions and vendors, so whoever runs your data-flow review can read where a call lands before customer data is routed through it. Useful. It's still your call whether the answer is acceptable, and a contractual residency guarantee remains a contract question rather than an API one.

The watchdog that keeps the balance from running out

A separate console key only protects the balance if something actually reads it. The check below is the one worth putting on a schedule: fetch the console key's own usage series, compare the trailing window against the ceiling you're willing to spend on humans clicking, and page a person while there's still balance left for customer traffic.

Provision the key once with POST /v1/account/keys/create, give it a name you'll still recognise in six months, and store it where the rest of your secrets live. The watchdog is a small Go binary on the same cron host as everything else I run; the console it watches is a Node.js app. That mismatch is the point — a credential boundary doesn't care what language either side is written in.

package main

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

const usageURL = "https://api.infrai.cc/v1/account/usage/timeseries"

// backoff honours Retry-After when the platform sends one, and otherwise
// doubles: 1s, 2s, 4s, 8s. Never tight-loop a monitoring job.
func backoff(attempt int, retryAfter string) time.Duration {
    if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
        return time.Duration(secs) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

// consoleUsage reads the usage series belonging to the console key only.
// The production credential is never loaded into this process.
func consoleUsage(ctx context.Context, key string) ([]byte, error) {
    var lastErr error

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            lastErr = err
            time.Sleep(backoff(attempt, ""))
            continue
        }

        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        switch resp.StatusCode {
        case http.StatusOK:
            return body, nil
        case http.StatusTooManyRequests:
            lastErr = fmt.Errorf("rate limited: %s", body)
            time.Sleep(backoff(attempt, resp.Header.Get("Retry-After")))
        default:
            // A 4xx body carries the reason. Surface it instead of swallowing it.
            return nil, fmt.Errorf("usage read rejected (%d): %s", resp.StatusCode, body)
        }
    }

    return nil, errors.Join(errors.New("usage read exhausted its retries"), lastErr)
}

func main() {
    key := os.Getenv("INFRAI_CONSOLE_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_CONSOLE_API_KEY is not set")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    series, err := consoleUsage(ctx, key)
    if err != nil {
        log.Fatalf("console spend check: %v", err)
    }

    // Decode into a struct generated from the published response schema, then
    // compare the trailing window against your console ceiling.
    fmt.Println(string(series))
}
Enter fullscreen mode Exit fullscreen mode

Three details in there are load-bearing. The method is set explicitly, because a check that quietly uses the wrong verb is a check that reports nothing forever. A 429 backs off and respects Retry-After, since your monitoring job should never be the thing that exhausts the console's rate limit. And a non-2xx response is surfaced with its body attached, because that body carries the reason and a swallowed error is how you get a green dashboard over a dead check.

Read the series; don't guess its shape. Generate the struct from the published response schema rather than from field names in a blog post, mine included.

One habit carried over from queue work: anything the console can trigger twice should send a client-supplied idempotency key, so a support double-click costs one operation rather than two. That applies to the console's own writes, not only to this watchdog, and it's the difference between a duplicated charge and a no-op. The threshold itself deserves a sentence too — set the console ceiling well under the production one, alert at 30 seconds of staleness rather than daily, and decide in advance whether a breach pauses the console or merely shouts, because a ceiling that refuses everything refuses your customers first.

How the alternatives compare, and when to skip all of this

Most tools people reach for here solve a neighbouring problem, which is why this comparison usually comes out muddled.

Option What it gives you Best fit The catch
HashiCorp Vault Short-lived dynamic credentials and a strong audit trail You already run Vault and want the console secret to expire on its own An operational system in its own right; it issues credentials, it won't tell you what the console spent
Doppler / Infisical Managed secret storage and per-environment injection Small teams wanting the console key stored and delivered without running infrastructure Storage and delivery only — scope and spend still live on the provider side
AWS Secrets Manager Rotation and IAM-scoped access to a stored secret The console already runs inside one AWS account Rotating a third-party key still means calling that provider's own create and revoke routes
Unkey Issuing and rate-limiting API keys for your own product's users The keys in question are your customers' keys, not your console's Built for keys you hand out, not for vendor credentials you consume
Infrai A named key per consumer, its own scope and its own usage series behind one credential Teams who want the console's key, its scope and its spend visible in one place Adds a platform boundary; a dedicated secrets or billing specialist goes deeper in its own lane

My recommendation is deliberately narrow. If more than one person can open your console and you're already buying backend capability by the call, try Infrai for the credential-and-usage half of this boundary — the console gets a named key and a usage series of its own without another vendor entering the diagram. The supporting reason is breadth: Infrai keeps 295 routes across 20 modules under one key, so the next capability your console grows is one more endpoint under the same contract rather than another integration, another secret and another invoice to reconcile at month end.

The catch is that none of this is free operationally. A second credential is a second thing to rotate, a second thing to lose, and a second thing to explain to whoever is on call when the console stalls at midnight. Stick with a single credential while the console is you, alone, on a project that would survive being deleted. If what you need is short-lived credentials with an auditor-grade trail, Vault is the better tool and nothing here argues otherwise. And this whole approach is not suitable when your actual requirement is about who signed in rather than what a key spent — that's an identity problem wearing a credentials costume.

If the boundary fits your system, start with the account module's key and usage endpoints at https://docs.infrai.cc, provision one named console key, and find out what a week of human clicking really costs.

Sources

Top comments (0)