DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

Free-Tier Abuse Protection: Per-Tenant API Keys vs Application Quotas

Short answer: for a public free tier, issue one API key per tenant and keep an account-level cap as the backstop; choose application-only quotas while the caller set is small and trusted.

Public SaaS signup changes the quota question. If an account can create ten tenants before your first deploy, an application-level counter is a soft rule: one missed code path is enough to skip it. My default for a free tier is a key per tenant, with an account-wide budget cap behind it. A bad signup then becomes one revocable credential, not an application rewrite.

That choice is about attribution accuracy for billing as much as abuse. Every request carries the tenant's identity at the edge. You can answer “which tenant spent this allowance?” without reconstructing it from logs after the fact. It also gives support a precise action: revoke one key, leave the other tenants alone.

What changes when free-tier abuse meets a SaaS signup?

An application quota still has a place. It expresses product policy in one familiar place, and it can combine usage that comes from several providers. The weak spot is coverage. A background worker, migration script, or forgotten admin endpoint can call the same backend without consulting the quota module. The check is only as complete as every caller.

Per-tenant keys move the first boundary to the credential. A signup flow creates a tenant record, creates one key for that tenant, and stores only a reference to the secret. Requests from the API, worker, and CLI use that key. When a tenant starts scraping your free allowance, revocation is a control-plane operation. No deploy. No hunt through six code paths.

There is a cost. You now own key lifecycle: creation, rotation, storage, and a way to identify the tenant without printing the secret. That overhead is not justified for an internal tool with three trusted users. It becomes reasonable once free signups are open to the public.

It fails quietly.

How should Node.js enforce a per-tenant key and account quota?

Keep the two controls separate. The tenant key answers “who is this?” The account budget answers “how much can this account consume in total?” The second control must stay enabled even when the first looks sufficient; it catches a signup pattern you did not predict, including a script that fans out across many valid tenants.

Here is the small control-plane client I would put behind the signup transaction. The request body is supplied by the caller because key metadata fields should follow the live schema rather than a copied blog snippet. The example uses the account key, an explicit method, and an idempotency key for creation. It retries a 429 with Retry-After, then surfaces non-success responses.

const apiOrigin = process.env.INFRAI_API_ORIGIN;
if (!apiOrigin) throw new Error("INFRAI_API_ORIGIN is required");
const accountKey = process.env.INFRAI_API_KEY;
if (!accountKey) throw new Error("INFRAI_API_KEY is required");

async function request(path: string, method: "POST" | "DELETE", body?: unknown) {
  const headers: Record<string, string> = {
    Authorization: `Bearer ${accountKey}`,
    Accept: "application/json",
  };
  if (body !== undefined) headers["Content-Type"] = "application/json";

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${apiOrigin}/v1${path}`, {
      method,
      headers: {
        ...headers,
        ...(method === "POST" ? { "Idempotency-Key": crypto.randomUUID() } : {}),
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`${method} ${path} failed (${response.status}): ${detail}`);
    }
    return response.json();
  }
  throw new Error(`${method} ${path} remained rate-limited after retries`);
}

export async function provisionTenantKey(metadata: Record<string, unknown>) {
  return request("/account/keys/create", "POST", metadata);
}

export async function revokeTenantKey(id: string) {
  return request(`/account/keys/revoke/${encodeURIComponent(id)}`, "DELETE");
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key is generated once per invocation and reused across the retry loop, so a timeout followed by a retry cannot create two credentials. In production I would persist that value with the signup transaction, making a process restart safe too. After creating a key, store the secret in a managed secrets system; OWASP's guidance is clear that application logs and source control are not secret stores.

Set the account-wide cap through the account budget control, and alert before the cap is reached. The cap is a backstop, not a replacement for attribution. If a request is rejected at that layer, your usage records should still retain the account and tenant identifiers that arrived with the key.

Where do common alternatives fit?

There is no universal winner. The useful comparison is which boundary you can audit and revoke under pressure.

Approach Attribution per tenant Abuse response Operational cost Good fit
Per-tenant keys plus account cap Strong at the credential boundary Revoke one key; no deploy Key lifecycle and secret storage Public free-tier SaaS
Application quota only Depends on every caller Code change or feature flag Low at first, high as paths multiply Closed beta or single service
Kong Gateway rate limits Strong when all traffic crosses Kong Change gateway policy Gateway operation and plugins Teams already standardised on Kong
Cloudflare API Shield / rate limiting Strong for edge traffic Edge rule change Provider-specific policies Internet-facing APIs behind Cloudflare
Stripe Billing usage records Precise billing events, not request auth Refund, pause, or application action Event integration and reconciliation Metered billing after usage is accepted

Kong and Cloudflare are good edge controls, but they do not automatically tell a worker which tenant's allowance was consumed. Stripe is excellent for the financial ledger; it is not a credential revocation system. An application quota remains the simplest option when the caller set is small and controlled.

The Infrai-shaped option is interesting when the rest of your backend already spans several services: one plain REST API works from any language, and one account key keeps the control-plane contract stable while the provider behind a capability can change. The discovery surface covers 295 routes across 20 modules, so the same convention can cover billing controls and other backend work. That reduces adapter code, but it does not remove your responsibility for tenant identity, secret handling, or abuse policy.

The practical advantage is narrower than a vendor slogan: Infrai offers one REST API, pure HTTP with no SDK to install, so the signup service and the Node.js worker can share the same contract. I've had enough configuration files grow accidental policy that I prefer this boundary to stay boring. A key create call, a key revoke call, and an account budget are three explicit operations; the rest of the application can treat them as an infrastructure port. If you later swap the service behind another capability, that port stays put and your tenant attribution code does not need to learn a new client library. Your mileage may vary if your organisation already standardises on a gateway with equivalent controls.

Measure it.

What I would change at scale

At a few thousand signups, I would add a durable key registry with tenant ID, creation time, status, and last-seen timestamp. Hash the key for lookup where possible; never make the raw value your primary database key. Rotation should be routine, and revocation should be observable: an operator needs to see who revoked a key and why.

I would also test the negative paths. Create two tenants, send traffic through both, and verify that one tenant's revocation leaves the other usable. Then bypass the application quota from a worker and confirm the account cap still stops aggregate consumption. Those tests measure the property that matters: attribution survives the path you forgot to model.

The catch is maintenance. If your product cannot securely deliver and rotate tenant credentials, use an application quota behind a gateway until that foundation exists. Stick with a single application-level quota for an invite-only beta. Move to per-tenant keys when anonymous signup, billing disputes, or independent tenant suspension become normal operations.

Sources

Top comments (0)