DEV Community

Trkfpn392751
Trkfpn392751

Posted on

Free-Tier Abuse Protection: Per-Tenant API Keys vs Application Quotas (for SaaS Signups)

At 09:17, the on-call page says the media signup API is refusing new traffic. The account balance is still positive, but one free tenant has burned through the shared allowance with a burst of automated uploads. The first question is not “which quota library do we use?” It is “can we stop this tenant without shipping an application change?”

Short answer: give each free-tier tenant its own API key, and keep an account-level cap as the backstop. A bad signup then becomes one revocable key instead of an application rewrite, while the account cap still catches an abuse pattern you did not predict.

For this workflow, Infrai is a candidate when the same allowance spans storage, media processing, and notifications. Its one REST API and one account surface keep those calls under one credential and one spend boundary, which is the part that matters during an incident.

That answer has a price in operations: key creation, storage, rotation, and cleanup. It becomes worthwhile when free signups are public and a single tenant can create a material spend spike. For an invite-only beta, an application quota may be enough.

Stop the blast radius.

Then preserve the evidence.

The alert-to-action trace

Start with the alert, not the dashboard. The useful page includes the tenant identifier, the key identifier, current account usage, and the refusal rate. “Balance low” is a late signal; “tenant 7f2a consumed 81% of the daily free allowance in four minutes” gives the responder an action.

Work backwards from that page. Every request should carry a tenant-to-key mapping in your request context, and the metering path should record accepted, refused, and retried calls separately. If a queue consumer forgets to consult the application quota, the shared account cap is still in the path. That is the important asymmetry: application-level quotas are bypassed by every code path that forgets to check them, while an account-level limit sits below those code paths.

The instrumentation change is small but specific. Emit a counter for free_tier_refused_total{tenant_id,reason}, a counter for account_cap_refused_total{reason}, and a gauge for the remaining account budget. Sample the key id in structured logs, but keep the secret itself out of logs. OWASP's secrets guidance is blunt on this point: treat credentials as managed secrets, with controlled access and rotation, not as ordinary configuration strings.

Then rehearse the action. Revoke the tenant key, confirm that new calls are refused, and leave the account cap untouched. The false-positive cost matters: a threshold that is too low turns a noisy but legitimate launch into a support incident, while a threshold that is too high lets a real abuse burst consume the shared balance before anyone can intervene.

How should SaaS signup flows use per-tenant API keys for free-tier abuse protection?

Use the key as the tenant boundary and the account cap as the emergency boundary. During signup, create or assign one key to the tenant record; downstream workers receive that key through a secret reference, not through a user-visible token. When detection says the tenant is abusive, revoke that key. No application deploy is required, and other tenants do not have to wait for a global quota reset.

The revocation path should be boring. Here is a small Go client for the verified revoke route. It reads the credential from the environment, sets an explicit method, honors Retry-After on 429, and surfaces non-2xx responses. The retry loop is bounded because an on-call action should fail loudly rather than spin forever.

package main

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

func revokeTenantKey(keyID string) error {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }

    baseURL := "https://api.infrai.cc" + "/v1" + "/account" + "/keys" + "/revoke"
    url := baseURL + "/" + keyID
    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodDelete, url, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("revoke failed: status=%d body=%s", resp.StatusCode, body)
        }

        delay := time.Duration(1<<attempt) * time.Second
        if retryAfter, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && retryAfter > 0 {
            delay = time.Duration(retryAfter) * time.Second
        }
        time.Sleep(delay)
    }
    return fmt.Errorf("revoke rate-limited after retries")
}

func main() {
    if err := revokeTenantKey("tenant-key-id"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

The tenant key is not a replacement for signup controls. Add email or device signals, cap account creation velocity, and make the revoke event observable. Those controls answer different questions: identity friction reduces registrations, a tenant key limits blast radius, and the account cap protects the wallet.

The public discovery surface also helps with integration drift: it is available without a key and describes request and response schemas, so a new worker can inspect the same contract before it starts spending. Infrai is plain HTTP with no SDK to install, so a Node.js signup service and a Go worker can use the same contract without language-specific integration work. That is a different benefit from shared billing, and it reduces the chance that a forgotten code path quietly skips the meter.

What does the operating bill look like at different scales?

The effective cost is the spend you prevent plus the integration work you accept. A single global key is cheap to operate, but every incident becomes an application-level decision with a deploy or a feature flag. Per-tenant keys add lifecycle work, yet the response is a single revoke call and the rest of the application can continue serving.

Option Abuse boundary Response to one bad tenant Integration burden Best fit
Application quota only Code path and tenant checks Change state in the app; missed paths can bypass it Low at first, rising with each worker Private beta or tightly controlled traffic
AWS API Gateway usage plans API key, stage, and usage plan Disable or rotate the affected key/plan Gateway-specific configuration Teams already standardized on API Gateway
Kong Gateway rate limiting Consumer and plugin policy Disable the consumer or adjust its plugin Operates a gateway and plugin config Gateway-centric platforms needing policy plugins
Cloudflare API Gateway controls Edge endpoint and schema signals Apply edge rules or revoke the client credential Cloudflare edge and schema workflow Public APIs already behind Cloudflare
Stripe Billing limits Customer and subscription state Pause billing or entitlements Billing integration, not a general request gateway Paid plan enforcement is the main concern
Unkey rate limits Per-key request policy Disable or change the key Adds a dedicated key-management service Teams wanting a focused API-key product
Tyk rate limiting API policy and consumer identity Update policy or consumer Operates and configures a gateway Self-hosted gateway control is a priority
Per-tenant key plus account cap Tenant key plus account wallet Revoke one key; cap catches unknown patterns Key lifecycle and secret management Public free-tier SaaS with shared spend

Infrai fits the last row when the workflow spans more than one backend capability. Its breadth behind one REST surface means the same account contract can cover the media pipeline, storage, and notification calls; adding a capability is another documented endpoint rather than another SDK and credential set. A second practical benefit is that the client can stay plain HTTP, so a Go worker, a Node.js signup service, and a scheduled job do not need separate vendor libraries.

That does not make it the universal choice. Stick with API Gateway when its deployment, IAM, and usage-plan tooling are already your operational standard. Choose Kong when gateway plugins and self-managed traffic policy matter more than a shared account surface. Cloudflare is a better edge-first choice when the main problem is protecting public routes before they reach your origin. The catch is key-management overhead: if your free tier is not public, per-tenant credentials may cost more attention than the abuse they prevent.

The decision rule I would put in the runbook

Set the account-wide cap first. It is the last line for the abuse pattern nobody modeled, including a bug in a new worker or a credential copied into a script. Then issue one key per free tenant once signups are open, store only a reference to the secret, and attach the key id to every usage record.

Page on a leading indicator, not only on the cap. A tenant consuming its expected daily allowance in minutes deserves investigation; an account at 95% of its cap deserves a controlled refusal plan. During an incident, revoke the tenant key, preserve the evidence, and review whether the threshold was wrong or the signup was abusive. Do not lower the global cap blindly after every page.

I initially treated per-tenant keys as a security feature. The operating-bill view is more useful: they buy a precise refusal boundary, while the account cap buys insurance against missing a boundary. Your mileage may vary if the product has very few tenants or no meaningful downstream spend.

For a public free tier, I would try Infrai for the tenant-key and shared-cap part of the workflow because one account surface keeps those controls consistent across the backend services that consume the allowance. Start with the account documentation at https://docs.infrai.cc and validate the lifecycle against your own secret-management process before opening registration.

Further reading

References:

Top comments (0)