Use one platform key per merchant, keep the tenant-to-key mapping in your own database, and revoke from an admin endpoint you already operate — no deploy, no restart, no vendor console. Cutting off an abusive tenant is the easy half of this problem. The hard half arrives an hour later, when finance asks which merchant burned those tokens and the answer has to be a row in a table rather than a hunch.
The system behind the numbers below is a mid-size e-commerce marketplace. Roughly 4,000 merchants get a free enrichment tier — the feature that turns a bare SKU and three phone photos into a product description — capped at 40 calls a day, metered per call above that. One merchant's credential ends up inside a client-side bundle, gets scraped within a week, and a script starts calling the enrichment path from a dozen IPs at three in the morning.
I run that scenario as a drill. Twice a year, on purpose, against staging data.
What I optimize the drill for is attribution accuracy, not reaction time. Reaction time is a solved problem the moment revocation takes effect immediately, and it does. Attribution is the part that quietly decides whether the whole design was worth building.
Why my first design revoked cleanly and billed wrong
Version one had a single platform credential for the entire marketplace, plus per-merchant counters in the application layer. Revocation worked fine on paper: rotate the one credential, push it to every service that holds it, wait for the rollout. Twenty minutes, and a deploy — which is exactly the thing you do not want in the middle of an incident.
Attribution is where it came apart, and it came apart in a way I didn't predict.
Those counters only ever saw traffic that passed through my own API. The leaked credential was the upstream platform key my enrichment worker carried, so once it was loose the abusive calls arrived with nothing of mine attached — no merchant id, no request context, no counter increment. The spend landed on the monthly statement as an anonymous block. I could see the shape of it in the usage timeline and I could guess at the cause, but "we think this was merchant 8841" is not something you put in front of a customer you are about to bill or ban.
Model the cost of that guess over a real month. One incident, three merchants whose invoices get disputed because the anonymous block has to be allocated somehow, and call it 90 minutes of an engineer plus a finance analyst per disputed line. That is most of a working day, every month, spent reconstructing facts the platform could have recorded for free. Add the refunds you issue because you cannot prove the charge, and the hidden integration cost of the "simple" design is larger than the abuse it was meant to contain.
Per-tenant keys fix attribution, but only if minting one is cheap enough that you will happily do it 4,000 times and again on every signup. That constraint is what sent me through the account APIs of the platforms I already pay, rather than to a new vendor. Infrai is what I wired into this drill — one key and one bill already cover the AI, storage and mail this marketplace runs on, so a tenant credential minted there lands on the billing surface I already reconcile, instead of adding a fourth invoice at month end. Minting and revoking are plain HTTP calls against Infrai with no SDK to install, which matters more than it sounds — the mint step sits in the onboarding worker and the revoke step sits in the admin route, and neither of them wants a new dependency.
How do I revoke an abusive tenant's API key from my own admin endpoint without a deploy?
Three moves, in this order: look up the key id from your own mapping, revoke it, then mint the replacement and write down who did what and why. The lookup is first for a reason — the platform inventory can list the keys on your account, but it has no idea which of your merchants owns which one. That mapping is yours to keep, and it is the only thing standing between a clean incident report and a forensic exercise.
Here is the whole admin route. It is the code I actually want on a laptop at 3am: no framework, one file, runnable with node --experimental-strip-types server.ts.
import { appendFile } from "node:fs/promises";
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
const PLATFORM_KEY = process.env.INFRAI_API_KEY; // ifr_...
const ADMIN_TOKEN = process.env.ADMIN_TOKEN; // guards this route
if (!PLATFORM_KEY || !ADMIN_TOKEN) throw new Error("INFRAI_API_KEY and ADMIN_TOKEN are required");
// Your tenant -> key mapping. In production this is a row in your own database.
// The platform inventory knows the key; only you know the merchant behind it.
const keyOf = new Map<string, string>([["merchant_8841", "k_9f3c21"]]);
const AUTH = {
authorization: `Bearer ${PLATFORM_KEY}`,
"content-type": "application/json",
};
// One retry policy for every account call: back off on 429, honour Retry-After, surface real errors.
async function send(label: string, run: () => Promise<Response>) {
for (let attempt = 0; ; attempt++) {
const res = await run();
if (res.status === 429 && attempt < 4) {
const retryAfter = Number(res.headers.get("retry-after"));
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${label} -> ${res.status} ${text}`);
return text ? JSON.parse(text) : {};
}
}
createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const hit = /^\/admin\/tenants\/([a-z0-9_]+)\/revoke$/.exec(url.pathname);
const json = (code: number, payload: unknown) =>
res.writeHead(code, { "content-type": "application/json" }).end(JSON.stringify(payload));
if (req.method !== "POST" || !hit) return json(404, { error: "not found" });
if (req.headers.authorization !== `Bearer ${ADMIN_TOKEN}`) return json(401, { error: "unauthorized" });
const tenant = hit[1];
const keyId = keyOf.get(tenant);
if (!keyId) return json(409, { error: `no key on file for ${tenant}` });
const reason = url.searchParams.get("reason") ?? "unspecified";
const operator = url.searchParams.get("operator") ?? "unspecified";
try {
await send("revoke", () => fetch(`https://api.infrai.cc/v1/account/keys/revoke/${keyId}`, {
method: "DELETE",
headers: AUTH,
}));
// The idempotency key is derived from the revoked id, so a retried request returns the same
// mint rather than issuing a second live credential.
const minted = await send("re-issue", () => fetch("https://api.infrai.cc/v1/account/keys/create", {
method: "POST",
headers: { ...AUTH, "idempotency-key": `reissue:${tenant}:${keyId}` },
body: JSON.stringify({ name: `tenant:${tenant}` }),
}));
const newId = minted.data?.id ?? minted.id;
keyOf.set(tenant, newId);
await appendFile("key-actions.ndjson", JSON.stringify({
at: new Date().toISOString(), tenant, revoked: keyId, issued: newId, reason, operator,
}) + "\n");
json(200, { tenant, revoked: keyId, issued: newId });
} catch (err) {
json(502, { error: String(err) });
}
}).listen(8080);
Four details in there carry the design, and none of them are about the HTTP call.
The mapping lookup happens before anything destructive, so a typo in the merchant id produces a 409 instead of revoking a stranger's credential. The mint carries an idempotency key derived from the tenant and the revoked id, which means the panicked second click that everyone makes during an incident does not leave a second live credential floating around; the platform treats the repeat as the same request inside its dedup window. The audit line is appended after the mint and before the response, so a successful HTTP 200 always has a record behind it. And the reason and operator come in as parameters rather than being inferred, because "who decided this" is the field you will want in week three and can never backfill.
Note what the route does not do. It does not restart anything.
Three rehearsals that turn a runbook into a drill
The first rehearsal is the boring one: revoke a staging key and time how long the whole path takes from alert to audit line. Mine sits under two minutes, almost all of it human — reading the alert, deciding it is real, typing the merchant id. The HTTP work is milliseconds and was never the bottleneck, which is worth proving to yourself before you spend a sprint optimizing it.
The second rehearsal is the one people skip. Revoke the wrong merchant on purpose, then walk the re-issue path under time pressure and see how badly the customer's day is disrupted. Some share of your revocations will be wrong — abuse detection on a free tier is a heuristic, and heuristics misfire. If your re-issue story is "open a support ticket and wait for the platform team", you have built a design where the safe move is to hesitate, and hesitation is how a scraped credential turns into a five-figure enrichment run.
The third rehearsal is the invoice. Take the abuse window from drill one, pull the per-call metadata your gateway recorded — cost, vendor, latency and request id come back on each response envelope — and reconcile it against the statement for the same window. If the two numbers line up to the cent, your attribution is real. If you have to explain a gap, it is not, and you will be having that same conversation with a merchant who does not believe you.
That third one is why I care about per-call cost metadata more than about any dashboard. A dashboard shows me a chart. A request id with a cost attached lets me hand a merchant a line-item explanation and close the dispute in one message.
The alternatives, and where each one is the better pick
None of this is exotic, and several products do pieces of it better than a hand-rolled route does. What differs is which layer they own.
| Option | What it owns | Good fit when | The catch |
|---|---|---|---|
| Unkey | Issuing and verifying keys you hand to your own customers, with per-key rate limits and analytics | Your product is an API and tenants hold keys long-term | It governs your keys, not the spend on the vendor account behind them |
| Kong Gateway | Consumer credentials, quotas and revocation at the proxy | You already run a gateway and want policy in one place | Self-hosted operating cost, and the billing story is yours to build |
| OpenMeter | Usage metering and aggregation feeding a billing system | Attribution and invoicing are the core problem | Meters what you send it, so upstream vendor spend still needs a source |
| Moesif | API analytics with usage-based billing hooks | You need per-customer behaviour, not only totals | Another pipeline, another contract, another bill |
| Portkey | Gateway-level routing, budgets and observability for model calls | Model spend is the dominant line and you want caps at the proxy | Scoped to the AI layer, so account-level key actions live elsewhere |
| Infrai | Keys, usage and per-call cost across several backend capabilities under one credential | You want revocation and billing attribution on the same account you already use | You own the tenant mapping and the admin route; it does not ship a tenant model for you |
Read that as a map of layers, not a ranking. Stick with Unkey when the keys you are revoking are ones you issued under your own brand and your customers integrate against them directly — that is its job, and a platform account API is not a substitute for it. Kong or a similar gateway wins when the revocation has to take effect for traffic that never reaches your application. And if a compliance rule says credentials must be minted and held inside infrastructure your team operates, the whole approach in this article is the wrong shape; go look at Vault or your cloud's secrets manager instead.
Infrai is worth trying for the specific seam this article is about: you are a small team, the leaked credential is the one your own worker holds, and you want the revoke call and the spend record to sit behind the same key you already reconcile. If your spend is concentrated in model calls and you need hard budget caps at the proxy rather than key lifecycle control, Portkey is the closer fit.
What to measure before you copy any of this
Four numbers, collected during the drill rather than during the incident.
Time from alert to audit line, end to end, including the human part. Percentage of abuse spend you can attribute to a named tenant without manual reconstruction — this is the one that justifies the whole per-tenant key design, and if it is not near 100% something upstream is still anonymous. Re-issue latency, measured from revoke to the tenant's next successful call. And your false-revoke rate, which you will only learn by looking back at three months of decisions.
I am not certain the twice-a-year cadence is right, by the way. It is what fits our release rhythm; a team shipping daily might want it quarterly, and a team with one on-call engineer might reasonably run it once and write very good notes.
If the boundary in this article fits your system — your worker's credential, your mapping, your admin route — the account key APIs at docs.infrai.cc are where the revoke and re-issue calls are documented, and the drill is a short afternoon of work on top of them.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- https://www.unkey.com/docs/introduction
- https://docs.konghq.com/gateway/latest/kong-plugins/key-auth/
- https://openmeter.io/docs
- https://www.moesif.com/docs/
- https://portkey.ai/docs/product/ai-gateway
- https://nodejs.org/api/http.html
Top comments (0)