DEV Community

RhettMurray8263
RhettMurray8263

Posted on

Healthtech SaaS Free-Tier Abuse: API-Key Attribution vs Application Quotas

Short answer: Free-tier abuse protection in a healthtech SaaS should start with per-tenant API keys, then add an application-level quota as a backstop. That combination keeps a prepaid balance from draining unattended while preserving an audit trail for every access decision. A single global cap is simpler, but it cannot answer the question an auditor will ask: which tenant, key, and request consumed the balance?

I evaluate this as an experiment, not a pricing exercise. The pass condition is an explainable deny: a reviewer can connect a request to a tenant, a credential, a quota decision, and the remaining prepaid balance. Cost matters, but attribution is the constraint that makes the result useful.

Why a global quota misses the failure mode

An application quota counts traffic for the whole SaaS application. It is good at protecting a provider account from a runaway loop and easy to enforce at one gateway. It is weak at partitioning responsibility. If one signup scripts thousands of requests, every legitimate tenant shares the same noisy signal until the application cap trips.

Per-tenant keys create a narrower control boundary. A key can be disabled, rotated, or rate-limited without stopping other tenants. The key identifier also becomes an audit field, alongside tenant ID, request ID, decision, and balance snapshot. Never put the raw secret in that record; store a hash or a secret reference and follow the secret lifecycle guidance in the OWASP Secrets Management Cheat Sheet.

The trade-off is operational work. Key issuance, rotation, and revocation need durable state, and a tenant with several keys needs an aggregate ceiling as well as a per-key ceiling. Application quotas remain valuable as the outer circuit breaker. They are the seat belt, not the steering wheel.

How should a SaaS signup combine tenant API keys and application quotas?

Use two decisions in a fixed order. First authenticate the presented key and resolve its tenant. Then evaluate the tenant's prepaid balance and tenant quota. Finally apply the application quota to contain a broader incident. Each decision should emit a structured event, even when the answer is allow.

Here is a small policy function I use in an evaluation harness. It has no vendor dependency, and the same fields can be sent to a log pipeline or an append-only audit store.

from dataclasses import dataclass
from typing import Literal

Decision = Literal["allow", "deny"]


@dataclass(frozen=True)
class Request:
    tenant_id: str
    key_id: str
    estimated_units: int


@dataclass(frozen=True)
class Limits:
    tenant_remaining: int
    application_remaining: int


def decide(request: Request, limits: Limits) -> tuple[Decision, str]:
    if request.estimated_units > limits.tenant_remaining:
        return "deny", "tenant_balance_exhausted"
    if request.estimated_units > limits.application_remaining:
        return "deny", "application_quota_exhausted"
    return "allow", "within_limits"


request = Request(tenant_id="clinic-42", key_id="key_7f3", estimated_units=12)
limits = Limits(tenant_remaining=40, application_remaining=100)
decision, reason = decide(request, limits)
print({"tenant": request.tenant_id, "key": request.key_id,
       "decision": decision, "reason": reason})
Enter fullscreen mode Exit fullscreen mode

The example deliberately estimates units before spending them. For a model or data-enrichment call, reserve the estimate, record the actual usage later, and reconcile the difference. If reservation cannot be atomic with the balance update, two concurrent requests can both observe credit and overspend it. That race is more damaging than a slightly conservative estimate.

Consider a clinic with 20 units left and two browser tabs submitting an 18-unit job at the same instant. A naïve read-then-write flow lets both tabs pass, records two approvals, and leaves the ledger negative after the writes settle. The audit trail then tells a true story about each request but fails to explain why the balance crossed zero. An atomic reservation turns one decision into an approval and the other into a denial, with the reservation ID linking the event to the ledger row. The same mechanism handles retries: a repeated correlation ID returns the original decision instead of charging twice. This is where a queue, database transaction, or gateway plugin becomes an implementation detail rather than the policy itself. Test the boundary with randomized concurrency and assert the invariant that committed reservations never exceed available units.

What should the audit record contain when free-tier abuse happens?

Capture an event schema before choosing a gateway. At minimum, include an event ID, timestamp, tenant ID, key ID, authenticated principal, endpoint class, estimated and actual units, balance before and after, quota scope, decision, reason, and a correlation ID. Keep policy version and actor type too; a support replay and an automated signup should not look identical.

An audit trail is only useful if it survives retries. Give each debit an idempotency key derived from the request's trusted correlation ID, and make the write path append-only from the application's perspective. Redact authorization headers and personal health information. OWASP's guidance is a useful baseline for secret storage, rotation, and access review, but retention and deletion rules still need to match your health-data obligations.

Short logs are tempting.

They are not enough.

When a balance reaches zero, the product should stop background work as well as interactive calls. A queue consumer needs the same policy check, or an attacker can switch from the signup endpoint to an asynchronous job and bypass the control that looked correct in a browser test.

When is an application-level quota the better choice?

Choose an application quota first when tenants are not independently authenticated, when traffic is intentionally pooled, or when the immediate risk is a single process exhausting a shared provider allowance. It is also a reasonable first layer for a prototype with no prepaid accounting.

The catch is auditability. A global counter cannot provide tenant-level attribution after the fact, and retrofitting that detail usually means changing event schemas, storage, and support tooling together. Stick with a pooled quota when the business truly sells pooled capacity; move to tenant keys when chargeback, revocation, or regulated access review becomes part of the service.

Do not use key count as a proxy for abuse. A legitimate clinic may rotate keys, while a scripted signup may create one key and behave badly. Score behavior using request rate, failed authentication, burst shape, and balance reservations, then let a human-reviewed policy decide thresholds. I'm not sure any fixed threshold stays correct across every care workflow, so keep thresholds configurable and test them against recorded, de-identified traces.

Run the same scenarios through both designs: normal clinic traffic, a signup creating repeated keys, a leaked key replay, concurrent reservations near zero balance, and a queue job arriving after revocation. Measure attribution completeness, time to revoke, false-positive denials, overspend after concurrency, and the percentage of decisions that can be reconstructed from logs alone.

The result should be a decision table owned by the team, not a magic number hidden in middleware. In my Python eval harness, I fail a policy change when a denied request lacks a tenant ID, key ID, reason, or policy version. That test catches regressions before a dashboard makes them look like an unexplained drop in usage.

The practical choice is conditional: per-tenant keys are the primary control for auditable prepaid access; an application quota is the broad containment layer. A pooled quota alone is suitable only when tenant attribution is outside the product's contract.

References

Top comments (0)