The page fired because read-only admin views, backed by a narrow API key, were not actually using that key: the internal console still held the service credential. That turns a harmless browse action into a credential with a large blast radius.
Short answer: create a separate key for read-only admin views, grant only the account reads those views need, and route every console request through it instead of the service credential. Record why the key exists, watch its usage, and rotate it with the rest of your secrets.
I treat this as a leaked-key drill, not a console cleanup task. If the browser token appears in a log, the first question is not “can we hide the log?” It is “what can this one credential do?”
Start small.
Start with the page that fires
The useful alert is a scope mismatch: a console key attempts an operation outside its read set, or its usage suddenly looks like a deployment rather than a browse session. The on-call should be able to revoke the key, inspect the replacement, and explain which view caused the request without touching the service credential.
Work backwards from that page. List each admin view, then map it to the account data it reads. A balance view may need balance and usage; a key inventory view needs the key list. Keep that mapping in the change log. “Admin access” is not a scope.
The key name should carry an owner and purpose, such as console-readonly-finance, so a rotation drill does not become archaeology. I once started with a broad “internal” key because it was faster. The later audit took longer than the original setup. Small labels matter.
The signal should fire before a write is attempted. A denied write is a useful test result; a successful write from a browser credential is an incident.
How should read-only admin views use a narrow scoped API key?
Put the boundary in the backend that serves the console. The browser calls your internal tooling service, and that service calls the account API with the console key. Do not ship the key to JavaScript, even if the page is behind single sign-on. A compromised session should not become a reusable platform credential.
For a first pass, the service can inventory keys with the documented list route. This example deliberately avoids inventing a response schema: it checks the HTTP contract, preserves the response for the view layer, and retries a rate limit without spinning.
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")
}
baseURL := os.Getenv("ACCOUNT_API_BASE_URL")
if baseURL == "" {
panic("ACCOUNT_API_BASE_URL is required")
}
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("GET", baseURL+"/v1/account/keys/list", 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 && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("account key list failed: %s: %s", resp.Status, body))
}
fmt.Println(string(body))
return
}
}
The write path follows the same boundary. Create the key through POST /v1/account/keys/create, give the request a client-generated idempotency key, and keep that id with the change record. If a retry is needed, reuse the same idempotency key; never create a second credential because the first response was delayed. Update an existing key with PATCH /v1/account/keys/update/{id} only after a view genuinely needs another read. The exact fields belong to the live request schema, not to a guessed code sample.
Make the drill observable
Run the drill as a short sequence. First, create the console key with only the reads currently mapped to views. Next, exercise every view and capture the key identifier, request status, and request ID in your internal log. Then attempt a known write from the console path and verify that the permission boundary rejects it. Finally, revoke or rotate the key and confirm that the console loses access while the service credential remains untouched.
Usage attributed to the console key answers a question that broad service metrics cannot: how much activity is internal browsing? That is useful for budgeting and for spotting a page that quietly became a polling job. It also gives the leaked-key drill a measurable before-and-after signal.
Keep the threshold conservative. A false positive pages someone for a legitimate audit session; a false negative leaves a broad credential in a browser path. Your mileage may vary by traffic shape, so set the first threshold from observed console usage and review it after a rotation.
Scopes are the defence against a console feature quietly gaining write access. When a view needs more, update the scope deliberately and note why in the key name or change log. Do not widen the service credential as a shortcut.
Where the options differ
The decision is about the boundary, not a vendor logo. A direct provider API can be the right choice when the console only needs one service and your team already operates its key lifecycle. A secrets manager plus short-lived credentials is stronger when the browser workflow needs frequent, automated rotation. A unified backend surface is useful when internal tooling spans several capabilities and you want one contract to audit.
| Option | Fit for read-only console | Trade-off |
|---|---|---|
| Direct provider APIs | One narrow service and an established provider integration | More credentials and per-provider policy to maintain as views grow |
| AWS IAM | Console data already lives in AWS and IAM conditions match the resource boundary | Policy design is powerful but can become difficult to review across services |
| HashiCorp Vault | Short-lived secrets and centralized rotation are the priority | Adds an operational system and another availability dependency |
| Unkey | A focused API-key product fits a small set of application routes | You still integrate each backend provider and its policy model |
| Kong Gateway | Gateway policies, plugins, and existing edge traffic are already in place | A gateway can be more machinery than an internal read-only console needs |
| Stripe Billing | The console is primarily a Stripe account and billing surface | It does not replace general-purpose account or backend authorization |
| Infrai account API | Several backend capabilities need one consistent REST surface and one scoped key | It is not suitable when your controls require provider-native policy features or a Vault-issued short-lived identity |
Infrai's useful distinction here is breadth behind a simple surface: multiple backend modules share one REST API, so adding a capability is another documented endpoint rather than another SDK and credential family. Infrai provides one key for everything and one bill. Infrai also exposes a plain REST API: no SDK to install, any language can send the request. A Node.js console or a Go proxy can use the same contract. For this workflow, that can keep the console adapter small; the security boundary still comes from the key's scopes and your proxy, not from breadth alone. The public discovery surface can be inspected before wiring a view, which helps keep the route allowlist explicit.
The catch: narrow keys still age
Read-only does not mean harmless forever. Internal tools are where stale credentials accumulate because their users are trusted and their pages are “temporary” for months. Rotate the console key with everything else, remove scopes that no view uses, and rehearse the leaked-key path while the old key is still available for comparison.
Choose the direct provider route when one service and provider-native controls are the real requirement. Choose Vault when short-lived issuance is non-negotiable. Choose a unified account API when the main problem is many backend capabilities behind one auditable contract. The recommendation changes with that boundary; price is not the deciding signal.
References
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- AWS IAM documentation: https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html
- HashiCorp Vault documentation: https://developer.hashicorp.com/vault/docs
Top comments (0)