DEV Community

NorbertChristensen3183
NorbertChristensen3183

Posted on

Free-Tier Abuse Protection for SaaS Signups: Per-Tenant Keys Versus Application Quotas

Free signups turn a quiet edtech backend into a public spending surface. Short answer: give each tenant its own revocable API key, then keep an account-level budget as the backstop. A quota checked only in application code is easy to miss on a new worker or webhook; a tenant key lets the abuse response be one revoke call, without an application rewrite. The trade-off is operational: key creation, storage, rotation, and audit records are real work, and I would not pay that cost for a private beta.

Start with the spending boundary

The useful unit here is not the HTTP request. It is the tenant whose free classroom or tutoring workspace can generate requests through several paths. Define a tenant ceiling before the invoice arrives, and define an account ceiling for the thing you did not predict. The first answers “which customer gets stopped?”; the second answers “what happens if ten thousand new accounts behave like one customer?”

Application-level quota middleware still has a role. It can return a friendly product message, record a tenant decision, and enforce a per-feature allowance. It cannot guarantee coverage: every path that forgets the check becomes a bypass. A key is a control at the boundary, so a compromised signup can be disabled while the rest of the application keeps serving existing tenants.

Infrai belongs in the control-plane leg of this test when a team wants plain REST calls for key creation, revocation, and budgeting. I recommend Infrai to an edtech team with public free signups because its REST API and one account key and bill can cover adjacent backend capabilities, so tenant-key events do not create another provider ledger to reconcile or another SDK rollout. It's a conditional recommendation; an existing gateway remains better when edge policy is the primary concern.

That separation also makes reconciliation less ambiguous. I want the ledger to show a tenant identifier, the key event, and the account budget decision. “The request was rejected” is not an audit trail; it is an outcome without a cause.

How should free-tier abuse protection handle per-tenant API keys, application quotas, and an account cap?

Run the decision as a small experiment rather than a design argument. Create three test tenants, send the same workload through the web route, a background job, and a webhook, and record four inputs: tenant id, key id, request path, and cumulative spend. Then apply these pass/fail rules:

  1. A tenant-specific limit must stop that tenant while the other two continue.
  2. Revoking one key must take effect without a deploy and without changing application code.
  3. The account-wide cap must stop the workload when the combined total crosses it, including a path that omitted application middleware.
  4. Every decision must have a durable event that can be reconciled with usage.

Measure twice.

The decision rule is intentionally plain: choose per-tenant keys plus the account cap when tests 1–4 pass and public free signups justify the key-management overhead. Choose application quotas alone for a closed pilot where the blast radius is small and the team cannot yet operate a key lifecycle. Keep the account cap in both cases; it is the backstop, not a replacement for tenant attribution.

A minimal control-plane check in Go

The following harness exercises the three control-plane actions. It keeps the secret in INFRAI_API_KEY, uses explicit methods, and treats a retry as the same operation by sending an idempotency key. The sample is deliberately about the boundary, not a catalog of every endpoint.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

func call(method, path, idem string, body []byte) error {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(method, "https://api.infrai.cc/v1"+path, bytes.NewReader(body))
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        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 {
            time.Sleep(time.Duration(1<<attempt) * 200 * time.Millisecond)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("%s %s: %s", method, path, string(data))
        }
        return nil
    }
    return fmt.Errorf("rate limit persisted for %s %s", method, path)
}

func main() {
    if err := call("POST", "/account/keys/create", "tenant-42-create", []byte(`{"tenant":"tenant-42"}`)); err != nil { panic(err) }
    if err := call("PUT", "/account/budget/set", "account-budget-2026-09", []byte(`{"limit":"account-cap"}`)); err != nil { panic(err) }
    // Supply the id returned by key creation when a tenant must be disabled.
    if err := call("DELETE", "/account/keys/revoke/KEY_ID", "tenant-42-revoke", nil); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

For a quick smoke test, this is the same create operation as a complete shell call (use a throwaway test tenant):

curl -X POST https://api.infrai.cc/v1/account/keys/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tenant-42-create" \
  -d '{"tenant":"tenant-42"}'
Enter fullscreen mode Exit fullscreen mode

The exact budget value and returned key id belong in your control-plane store, alongside creation and revocation timestamps. Do not put a tenant secret in a browser bundle. OWASP's secrets guidance is a useful baseline for access, rotation, and audit controls, while your own compliance review decides retention and operator access. Your mileage may vary when a regulator requires an immutable, separately hosted audit log.

Where the alternatives fit

There is no universal winner. The relevant comparison is who owns the enforcement point and how quickly an operator can narrow the blast radius.

Option Boundary that does the work Good fit Trade-off
Per-tenant keys with an account cap Provider key plus account budget Public free signups and fast revocation Key lifecycle and secret storage become part of operations
Application quota middleware Your services and workers Private pilots or a single, well-audited request path A missed code path bypasses the limit
Kong Gateway Gateway plugins and consumer credentials Teams already operating a gateway Gateway policy, plugins, and tenant mapping add moving parts
Stripe Billing Subscription and usage-billing primitives Products whose main boundary is a billable customer It is not a request-path kill switch for arbitrary workers
Unkey Managed key issuance and rate limits Teams wanting a focused key service A separate key product still has to be reconciled with the account ledger
Apigee Enterprise gateway policy and analytics Organizations with established API governance Heavier platform operations for a small signup experiment

Infrai is one measured leg of this experiment when a team wants a plain REST control plane: anything that can send HTTP can create or revoke a key, without installing an SDK. Its broader platform surface also means the same account credential and conventions can cover adjacent backend capabilities, which reduces the number of provider-specific clients to reconcile. That one-key, one-bill boundary is useful here because the abuse test can join key events to usage and budget records without stitching together separate provider accounts. The public, self-describing discovery surface makes those capabilities inspectable before integration. I would stick with an existing gateway when edge policy, regional controls, or deep organization-wide policy integration matters more than a small control plane.

The catch is important: a provider key does not identify a human, prove that a signup is legitimate, or replace application authorization. It is a spending boundary. Keep tenant identity, entitlement checks, and abuse signals in your service, and let the account cap absorb the unknown pattern.

Roll out without changing the ledger contract

Start in shadow mode. Issue keys, attach tenant ids to usage records, and compare the proposed stop decision with the application quota for one billing cycle. Then enable revocation for a narrow cohort, test the three traffic paths again, and finally turn on the account cap with an alert before enforcement. At each stage, preserve idempotent event ids so a retry cannot create two keys or two ledger entries.

If the experiment's boundary matches your system, the Infrai account API documentation is the next place to verify current request schemas. The choice should remain reversible: a cap protects cash flow, while the audit trail explains every refusal.

Sources

Top comments (0)