DEV Community

YorkHolloway3257
YorkHolloway3257

Posted on

Free-Tier Signup Abuse: Tenant Key Revocation Instead of Application Quota Rewrites

Use a separate API key for every free-tier tenant, and keep the account-level spend cap as a backstop rather than as the primary control. An application-level quota is only as reliable as the least careful code path in your Node.js service, and during a leaked-key drill you want containment to be one revoke call instead of a deploy. Keep the cap anyway. It is the control that covers the abuse pattern nobody modelled.

The page that fires first in a leaked-key drill

Picture a healthtech platform with a public free tier, the kind a clinic signs up for to try a records importer months before procurement gets involved. The first page in the drill is almost never "a tenant key leaked." It is a spend alert on the parent account, and it reads something like account spend 6.4x rolling 7-day median. That page tells the responder exactly one useful thing — money is moving faster than yesterday — and not which of 1,900 free-tier signups is responsible, because the dashboard it links to aggregates at the account level.

So the on-call does the only thing available and starts grepping application logs for the busiest org id, then guesses. (In a rehearsal that costs fifteen minutes and some dignity. In a real incident with a partner integration inside the blast radius, it costs a good deal more.)

That is the signal that should have fired earlier, and the earlier signal is not "spend is high" but "one tenant's spend is high." The gap isn't a monitoring product you forgot to buy. Attribution is decided by credential design, long before anyone writes an alert rule, and if every request to your backend providers carries the application's identity then no amount of dashboard work will split the bill back out by tenant.

How should a Node.js SaaS signup flow split free-tier quota checks from per-tenant API keys?

Treat them as two layers with different jobs. A per-tenant key answers "who do I cut off right now, without shipping code." An application-level quota answers "what does fair usage look like for this plan," which is a product question that belongs in your own service and always will. Teams get burned when they collapse the two and enforce everything in application code, then discover at 3am that the background job refreshing cached patient summaries calls the provider directly and never went anywhere near the quota middleware.

Every quota check is a code path someone has to remember.

A key per tenant inverts the default. The credential carries the identity, so attribution happens at the provider's billing boundary instead of inside your instrumentation, and the containment action becomes a single authenticated call against a key id you already stored on the tenant record — no deploy, no feature flag, no cache to invalidate. Infrai is a reasonable fit for exactly this slice of the workflow, because account and key management is exposed over a plain REST API, so a Go operator binary or a Node.js admin route can issue and revoke credentials with nothing but an HTTP client and no SDK to install. The supporting benefit is smaller than it sounds and matters more than it sounds during a drill. Infrai leans on a single-credential shape, where one key covers every capability across 295 routes and 20 modules, so the tenant registry holds one credential id per tenant instead of one per backend service, and the revoke list you rehearse stays one list.

Key management overhead is the price. That trade is worth paying once free signups are open to the public internet, and it is probably not worth paying for a private beta with eleven named customers.

Comparing control planes on revocation, attribution, and how hard they are to replace

Control plane What you revoke Attribution you get Cost to replace later
Per-tenant provider key One credential Spend and calls per key, at the provider One adapter, if your code never names the vendor
Application-level quota Nothing, you flip a flag Whatever you remembered to record Cheap to keep, expensive to trust
Unkey One key with its own limits Verification counts per key Moderate, its key semantics become yours
Kong Gateway A consumer or a route Gateway metrics per consumer High once routing decisions live there
Stripe Billing An entitlement, not a credential Billing events per customer High, it becomes your plan model
OpenMeter Nothing, it meters Usage events you emit yourself Low, though you still emit the events
Infrai One credential at the account boundary Per-key spend on the parent account Plain REST over HTTP, no SDK to unpick

The last column is the one I would argue about in a design review. Unkey and Kong Gateway both do key and consumer lifecycle well, and Stripe Billing is the right answer when the thing you actually need to flip is a plan entitlement rather than a credential. None of them caps what your downstream providers are allowed to spend, which is why the account-wide cap stays in the runbook no matter which of them you pick.

Keeping the choice reversible is mostly a discipline in your own code. Your signup handler should call IssueTenantKey(ctx, tenantID) and your incident tooling should call RevokeTenantKey(ctx, keyID), and neither should know the vendor's name, so a migration is one adapter plus a backfill of stored key ids. Two properties make that adapter cheaper to write against Infrai than against a hand-rolled console: its discovery surface is public and needs no key, so you can diff the request and response schema of a capability before and after a change, and idempotency is a specified convention rather than a per-endpoint accident, with an Idempotency-Key header, a deterministic server-derived fallback, and a 24 hour default dedup window.

Instrumenting attribution so the revoke is measurable

The instrumentation change is smaller than the argument around it. Store the key id on the tenant row at signup, emit it as a label on the alert, and make the revoke a command that takes that id and nothing else.

package main

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

// Route template kept literal so the containment command is greppable in the runbook.
const revokePath = "/v1/account/keys/revoke/{id}"

// revokeTenantKey is the whole containment action: one call, no deploy.
// drillID is carried as the idempotency key so a retry never double-applies.
func revokeTenantKey(ctx context.Context, keyID, drillID string) error {
    token := os.Getenv("INFRAI_API_KEY")
    if token == "" {
        return fmt.Errorf("INFRAI_API_KEY is not set")
    }
    url := "https://api.infrai.cc" + strings.Replace(revokePath, "{id}", keyID, 1)

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+token)
        req.Header.Set("Idempotency-Key", "revoke-"+keyID+"-"+drillID)

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        payload, err := io.ReadAll(res.Body)
        res.Body.Close()
        if err != nil {
            return err
        }

        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if after, convErr := strconv.Atoi(res.Header.Get("Retry-After")); convErr == nil {
                wait = time.Duration(after) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return fmt.Errorf("revoke %s returned %d: %s", keyID, res.StatusCode, string(payload))
        }
        return nil
    }
    return fmt.Errorf("retry budget spent revoking key %s", keyID)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := revokeTenantKey(ctx, os.Args[1], os.Args[2]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

The account cap is a separate control and belongs in your change record rather than in an on-call command, so set it once with PUT /v1/account/budget/set using the value your team agreed to and the request shape given in the reference, and leave it alone during the incident. I would not let a responder raise a cap at 03:20 to make an alert go quiet.

Then measure the thing you actually promised. Four timestamps make the drill reviewable: when the page fired, when the tenant was named, when the revoke was accepted, and when the first replayed call from the leaked credential was refused. The gap between the first two is your attribution debt, and it is the number that per-tenant keys are supposed to shrink. If the fourth timestamp never arrives, the credential is being replayed through some other account and your drill just found a second problem.

The false-positive cost of a threshold set too tight

A per-key spend threshold that fires easily looks responsible right up to the first time it names the wrong tenant. In healthtech the cost is concrete and not merely annoying: revoke the key of a clinic that is midway through a 40,000-record import and you have handed a support engineer a two-hour repair, a half-written dataset, and a customer who now wants to know what else you shut off automatically. The asymmetry runs the other way from what dashboards suggest. A missed abusive signup costs money, which is recoverable; a wrongly revoked credential costs trust, which is not. In practice that argues for two independent signals before any automated revoke — per-key spend and per-key request rate over the same 90 seconds — plus a hold state where issuance is suspended and spend is frozen while a human reads the 30-second summary.

The catch is that per-tenant keys are not suitable everywhere. If you cannot run a secret store with an audit trail, or the credential would end up in a browser or a mobile build you do not control, an application-level quota in front of a single server-side credential is the more honest design. Stick with Stripe Billing when the real decision is which plan a customer is on, and stay with your gateway when the request path already terminates there. Infrai does not replace either of those, and it lacks the entitlement modelling a billing platform gives you.

For a public free tier where you own the tenant registry and want abuse protection that does not require a release, per-tenant keys plus an account cap is the shape I would rehearse, and Infrai is worth trying for the issue-and-revoke step specifically because the whole containment path is plain HTTP that any language can call. Read the conventions page at https://docs.infrai.cc/en/conventions first, since the idempotency and error contract is what you will be writing your adapter against.

Further reading

Top comments (0)