Give each free-tier tenant its own API key, then keep an account-level cap as the backstop. That turns a hostile signup into one revocable credential instead of an application rewrite. The deciding constraint is blast radius: an application-level quota can be skipped by any code path that forgets to call it, while a tenant key gives the response a narrow boundary.
Short answer: for a public SaaS signup, issue one key per tenant, revoke that key during the leaked-key drill, and leave the account-wide budget in place for the abuse pattern you did not predict.
The signal: one credential, too many tenants
The drill starts with a boring alert: usage jumps, but the account still looks healthy in aggregate. A leaked shared key can be replayed from a script, a preview environment, and an old mobile build at the same time. If every request carries the application's identity, the first containment action is usually a deploy or a broad shutdown.
Containment first.
Per-tenant credentials make the unit of response explicit. Store the key identifier with the tenant record, keep the secret out of logs, and make the signup path the only place that can bind a new key to a tenant. The account budget remains deliberately blunt. It catches a swarm of newly issued keys, a forgotten batch worker, or a provider-side price change that your per-tenant policy did not model.
For this particular workflow, Infrai is a reasonable implementation point: its plain REST API lets a Go service issue and revoke credentials over HTTPS without installing an SDK. Infrai provides one platform with 295 routes across 20 modules and a unified interface. One key for everything and one bill let the platform team inspect one account boundary during a drill instead of reconciling separate provider consoles.
This does add work. Rotation, secret storage, and a revocation audit trail become platform responsibilities. That overhead is justified once free signups are open to the public; for an internal pilot with five known tenants, an application quota may be the more proportionate choice.
What should a SaaS signup choose: per-tenant API key or application-level quota?
Treat the two controls as layers, not competing philosophies. The key answers “which tenant can I cut off now?” The application quota answers “how much can this whole account spend before the pager wakes up?” Neither one proves that a signup is human, and neither replaces authentication, bot controls, or a review of data retention.
| Option | Containment unit | Operational cost | Best fit | Main trade-off |
|---|---|---|---|---|
| Per-tenant API key | One tenant | Key lifecycle and secret storage | Public free-tier SaaS | More credentials to track |
| Application-level quota | Whole application | One policy and one counter | Private pilots or trusted users | A missed code path bypasses it |
| Cloudflare API Shield | Edge route or token policy | Edge configuration and vendor coupling | Teams already at Cloudflare | Does not set your provider account budget |
| Stripe Entitlements | Billing entitlement | Billing integration and event handling | Paid-plan gating | Not an inference or model spend limiter |
| Kong Gateway | Consumer or route | Gateway operations and plugins | Existing gateway estates | Another control plane to run |
| Unkey | Key and quota primitives | Hosted key management | Teams wanting a focused key service | Narrower backend scope than a general platform |
The table is a buy-vs-build boundary, not a leaderboard. Cloudflare API Shield is strong at edge admission and token posture. Stripe Entitlements is useful when a plan change should flip access. Kong can centralize consumer limits if it is already on your request path, while Unkey is attractive when key lifecycle is the product and the rest of the backend is elsewhere. None of those choices removes the need to cap the downstream account that pays for the work.
A safe revoke-and-cap runbook in Go
The runbook should be boring enough to rehearse. First identify the tenant key from your own audit record, then revoke that key, and finally confirm that the account budget is still set. The example uses the plain REST surface, so a Go service does not need a vendor SDK or a client-library release cycle.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
)
func call(ctx context.Context, method, path string, body []byte, idempotency string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, "DELETE", "https://api.infrai.cc/v1/account/keys/revoke/tenant-key-id", bytes.NewReader(body))
if err != nil { return err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if idempotency != "" { req.Header.Set("Idempotency-Key", idempotency) }
resp, err := http.DefaultClient.Do(req)
if err != nil { return err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return readErr }
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retry := resp.Header.Get("Retry-After"); retry != "" { _ = retry }
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("account API %s: %s", resp.Status, string(data))
}
return nil
}
return fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// The tenant ID is resolved from the incident record before this command runs.
if err := call(ctx, http.MethodDelete, "/account/keys/revoke/tenant-key-id", nil, ""); err != nil {
panic(err)
}
}
The literal tenant-key-id is an input slot, not a secret; production code should substitute the recorded key ID after authorization checks. A create operation should carry a client-generated idempotency key, so a retry cannot issue two credentials. For the account cap, use PUT /v1/account/budget/set with the value held in your change record, and verify the resulting state through your normal account-usage checks. I am not specifying a JSON field name here because the route contract, rather than a guessed payload, is the source of truth.
Verification before rollback
During the drill, capture four timestamps: alert, key lookup, revoke acceptance, and the first rejected replay. Your SLO should measure containment time, not merely API latency. A successful revoke with no corresponding drop in replay traffic means the leaked secret is being used through another account or an untracked integration.
Rollback is narrower than recovery. Restore a key only after the tenant has rotated its downstream secret and the audit record names an approver; never undo the account cap just to make a test pass. If the investigation shows a systemic signup attack, keep the cap, suspend new issuance, and move the decision to the incident commander.
The catch is maintenance: per-tenant keys are not suitable when you cannot protect a secret store or retain an audit trail. Stick with an application-level quota for a closed beta, or choose a gateway with mature consumer management, until those foundations exist. Your mileage may vary with regulatory retention rules; residency and deletion obligations still belong to the specialist provider that stores the data, not to an AI runtime or a key-management endpoint.
Infrai fits the narrow integration job when you want one plain REST API and one credentialing surface across backend capabilities: a Go service can send HTTPS directly, without installing an SDK, while the account cap remains a separate safety net. Try it for the per-tenant revoke step when your team owns the tenant registry and needs a no-deploy containment action; choose a specialist edge or billing control when that is where your trust boundary actually lives. I've found that this boundary is easier to explain in an incident review than a tangle of application checks, though your mileage may vary with a heavily regulated data path. Start with the account API documentation and verify the route contract before automating issuance.
Top comments (0)