In a media pipeline the bytes that cost you money never pass through your application code. A free-tier signup asks for an upload slot, your API hands back a presigned PUT URL, and the client pushes 4 GB of source video straight at the object store, which means the application-level quota check sitting in your Node.js request handler was consulted for the 200-byte metadata call and not for the upload, nor for the transcode job a queue consumer picks up thirty seconds later. Use a scoped API key per free tenant as the primary abuse protection for a public SaaS signup, and keep the account-wide spend cap underneath it as the backstop, because that pairing turns an abusive tenant into a revocable credential instead of an application rewrite.
That is the whole argument. What follows is what it costs.
Why the quota check in your request handler gets skipped
An application-level quota is a counter guarded by whichever code path remembers to call it, and in a media product the code paths multiply faster than the guards do. The interactive upload endpoint checks it. The webhook that a transcoding vendor calls back does not, because it was written by someone who reasonably assumed the work had already been authorized. Neither does the nightly re-encode backfill, the support tool that re-runs a failed transcript for a complaining customer, or the queue consumer that drains whatever the ingest service published. Every one of those is a place where the quota exists and is simply not consulted.
Middleware isn't a boundary. It's a convention.
There is a second problem that I care about more, because it decides whether the number you are enforcing is even true. An application quota is shared mutable state, usually parked in the cache tier for latency reasons, and read-then-write on shared mutable state under concurrency is the oldest bug in the catalogue — two workers both read 40 units remaining, both admit an 18-unit job, and the ledger settles negative. You can fix that with an atomic decrement or a database transaction, and you should. But then you have accepted that your abuse control now depends on the durability of a counter that was chosen for speed, and on a failover path you probably have not rehearsed. A revocation list in the provider's own account state does not have that property; it is durable because that is what account state is for.
Should each free-tier signup get its own API key, or is an application-level quota enough for a Node.js SaaS?
Both, in a fixed order, and the order is what people get wrong. The per-tenant key is the primary control because it is an identity: your workers present tenant A's credential when they do tenant A's work, so the provider's usage records are already partitioned by tenant without your logging having to be complete. The account-wide cap is the outer circuit breaker, and it earns its place precisely because it protects you from the abuse shape you failed to imagine.
Auditability is the axis that settles it for me. When someone asks which tenant burned the free tier last Tuesday, a global counter can only tell you that the total moved, and reconstructing the rest means trusting that every one of those forgotten code paths emitted a log line with a tenant id in it. Per-key attribution inverts the burden of proof — the spend is already attributed at the point of authentication, and your logs become corroboration rather than the only evidence.
The cost is bookkeeping: issuance, storage, rotation, revocation, and an aggregate ceiling per tenant once a tenant legitimately holds more than one key. That overhead is only worth paying once free signups are open to the public.
Issuing and revoking the key, and what the audit row has to survive
The mechanism is small. One call to mint a scoped credential at signup, one call to kill it during an incident, and a durable row that ties the two together.
import json
import os
import time
import urllib.error
import urllib.request
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/") # the platform's v1 base URL
ADMIN_KEY = os.environ["INFRAI_API_KEY"] # ifr_... admin key, never a tenant's
def call(method, path, body=None, idempotency_key=None, attempts=4):
payload = json.dumps(body).encode() if body is not None else None
for attempt in range(attempts):
req = urllib.request.Request(BASE_URL + path, data=payload, method=method)
req.add_header("Authorization", "Bearer " + ADMIN_KEY)
if payload is not None:
req.add_header("Content-Type", "application/json")
if idempotency_key is not None:
req.add_header("Idempotency-Key", idempotency_key)
try:
with urllib.request.urlopen(req, timeout=15) as res:
return res.status, json.loads(res.read() or b"{}")
except urllib.error.HTTPError as exc:
if exc.code == 429 and attempt < attempts - 1:
retry_after = exc.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2 ** attempt)
continue
raise RuntimeError("%s %s -> %d %s" % (method, path, exc.code, exc.read().decode()[:200]))
raise RuntimeError("%s %s: rate limited after %d attempts" % (method, path, attempts))
def issue_tenant_key(tenant_id):
# Same idempotency key on every retry of this signup, so a retry never mints a second credential.
status, body = call(
"POST", "/v1/account/keys/create",
{"name": "free-tier:" + tenant_id},
idempotency_key="signup:" + tenant_id,
)
print("issued", status, body["id"])
return body["id"] # persist this; revoke needs the id, not the secret
def revoke_tenant_key(key_id):
status, body = call("DELETE", f"/v1/account/keys/revoke/{key_id}")
print("revoked", status, body)
if __name__ == "__main__":
key_id = issue_tenant_key("acme-studio")
revoke_tenant_key(key_id)
Two details in there matter more than the HTTP. The first is that you must persist the returned key id, in the same transaction that creates the tenant row, before you answer the signup request — if you mint a credential and crash before recording its id, you have created spend you cannot attribute and an identity you cannot revoke without going and listing every key on the account to guess which one it was. Write-ahead, then respond. The second is that the create call carries an idempotency key derived from the tenant, so a retried signup is one credential rather than two, and the revoke is naturally idempotent because the post-condition is a state rather than an event.
The audit row itself should be append-only and boring: event id, timestamp, tenant id, key id, actor, reason, and the decision. Never the secret — store a reference or a hash, and follow the rotation and access-review guidance in the OWASP secrets-management cheat sheet. My rule is that any denied or revoked request whose record is missing a tenant id, a key id, or a reason counts as a failed audit, not a minor logging gap.
Where the alternatives actually sit
These products get compared as if they were substitutes, and they are not. Each one owns a different half of the problem.
| Option | What it actually controls | Where it stops |
|---|---|---|
| Unkey | Issuing, verifying and rate-limiting the keys your own customers present to your API | Governs inbound traffic to your service; it doesn't cap what your workers then spend upstream |
| Kong Gateway | Per-consumer rate limits at the edge, enforced before your app runs | Edge counters are per-route requests, not per-tenant spend, and presigned uploads never traverse the gateway |
| OpenMeter | Metering usage events into a ledger you can bill or quota against | It measures; issuance, revocation and enforcement remain yours to build |
| Stripe Billing | Usage-based prices and entitlements on the customer relationship | That is the invoice story, not a credential you can kill during an incident |
| AWS Secrets Manager | Durable storage, versioning and rotation of the credentials themselves | Storage has no concept of a ceiling, so nothing here slows a runaway signup |
Infrai fits the narrow slot this article is about when the same platform is already carrying other parts of the pipeline, because key issuance lives in the same REST API as the other 295 routes across 20 modules and the on-call engineer ends up inspecting one credential store instead of five. Billing works the same way behind that single credential, which is the part that actually reduces reconciliation work at month end. The boundary worth stating plainly is that those keys are credentials for calling the platform — it isn't built for authenticating your tenants' inbound requests to your own Node.js routes, so a gateway or a key-management service in front of your API is still a separate decision.
The catch with per-tenant keys is that they only pay for themselves at a certain scale. Stick with one application quota and a hard account cap when your free tier is an invite-only pilot with twenty accounts, when a human reviews every signup, or when tenant attribution is genuinely outside what you sell. I'm not sure there is a clean threshold here; somewhere between "self-serve signups" and "a few hundred of them" the revoke call stops being a nice property and starts being the thing that ends an incident in ninety seconds.
Rolling it out without a rewrite
Leave the account cap exactly where it is, then work inward. Mint the key in the signup transaction that already writes the tenant row, thread the key id through every job payload so the worker authenticates as that tenant rather than as your service account, and let existing tenants get their credential lazily on their next job instead of running a migration. The last step is the one teams skip: rehearse the revoke against a throwaway tenant, and confirm that the in-flight queue job holding that credential also stops, because an abuse response that only closes the front door while the backlog keeps draining your balance is theatre.
None of this makes the counter in your request handler wrong. It makes it the second line, which is where it was always going to be useful.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- https://www.rfc-editor.org/rfc/rfc6585.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://docs.konghq.com/hub/kong-inc/rate-limiting/
- https://github.com/openmeterio/openmeter
- https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html
Top comments (0)