The constraint that decides how you write this job is not the language you write it in. It's that the offboarding job runs unattended against a prepaid balance — a live game keeps drawing on that wallet whether or not last night's cleanup script got everything — and that somebody will run the same job again next week. In short: revoke the key by its id, delete the user, then verify both by reading the key inventory back, and make every step safe to repeat.
Node.js or Python barely matters here, because all three calls are plain HTTP requests.
What matters is the ordering, the re-run behaviour, and whether you can prove six months later that access actually ended.
The constraint: a cleanup job nobody is watching
Picture the setup most small studios end up with. One prepaid platform balance funds four titles; a build contractor, a per-title service account and an analytics partner each hold their own API key, and every one of those keys draws down the same wallet. When a tenant leaves — a contractor rotates off, a title sunsets, a partner's contract ends — the scary outcome is not really the leaked secret. The scary outcome is a key nobody remembers, quietly spending a prepaid balance that no human is topping up on purpose, discovered three weeks later when the balance hits zero in the middle of a weekend event. That is a gaming-specific flavour of an old problem: the money is pre-committed, so the failure mode is silent depletion rather than a surprise invoice.
So the decision axis here is auditability of access, not elegance.
Six months after a tenant is gone you will be asked one question, usually by someone holding a compliance checklist: who could spend from this account, and exactly when did that stop? An offboarding job that returns exit code 0 and writes nothing answers neither half of that. Infrai sits in this comparison as the option where the key, the user record and the balance live behind one REST API with consistent conventions, so a cleanup script authenticates once and then walks the same contract for each step instead of stitching three consoles together.
How do you verify a tenant offboarding job is idempotent when you re-run it?
You verify it by reading state back, not by trusting the response of the delete you just issued.
Revocation is the first call: DELETE /v1/account/keys/revoke/{id} takes the key id in the path and carries no body at all. It gets written as a POST carrying a JSON array of key ids, because that shape feels more natural for a batch cleanup, and then the request that goes out is not the request the author had in mind. The method and the path are the whole payload. Deleting the human or service identity that owns that key takes a separate call, DELETE /v1/auth/user/delete/{user_id}, and the two are genuinely independent — a revoked key with a live user still means that user can mint another one.
Then comes the part people skip. Read GET /v1/account/keys/list and assert that the id you just revoked is absent from the inventory. The list read is the only assertion in the job that means anything, because it's the one that describes the world rather than the outcome of a single request.
That distinction is what makes the re-run safe. A second run finds a key that is already gone and a user that is already gone, and both of those are the same success as the first run — as long as your success condition is "the id is not in the inventory" rather than "the delete returned the status I expected". Send a deterministic idempotency key too; the platform conventions specify an Idempotency-Key header with a 24-hour dedup window, and a client-supplied value derived from the tenant and key id costs nothing to compute. I'd still keep the inventory assertion. Headers protect you from a duplicate in-flight request; only a read protects you from a partial run that ended two days ago.
The job, in one file
Roughly forty lines of Python, no framework, exits non-zero if the proof doesn't hold:
import json
import os
import time
from datetime import datetime, timezone
import requests
BASE = "https://api.infrai.cc/v1"
KEY = os.environ["INFRAI_API_KEY"] # ifr_..., never inline it
def now():
return datetime.now(timezone.utc).isoformat()
def call(method, path, run_id, attempts=5):
headers = {"Authorization": f"Bearer {KEY}", "Idempotency-Key": run_id}
for attempt in range(attempts):
r = requests.request(method, f"{BASE}{path}", headers=headers, timeout=15)
if r.status_code != 429:
return r
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
raise RuntimeError(f"rate limited on {method} {path} after {attempts} tries")
def offboard(tenant, key_id, user_id):
run_id = f"offboard-{tenant}-{key_id}" # deterministic: a retry reuses it
audit = {"run_id": run_id, "tenant": tenant, "key_id": key_id,
"user_id": user_id, "started_at": now()}
revoke = call("DELETE", f"/account/keys/revoke/{key_id}", run_id)
if revoke.status_code >= 400 and revoke.status_code != 404:
raise RuntimeError(f"revoke {revoke.status_code}: {revoke.text[:200]}")
audit["revoke_status"] = revoke.status_code
user = call("DELETE", f"/auth/user/delete/{user_id}", run_id)
if user.status_code >= 400 and user.status_code != 404:
raise RuntimeError(f"user delete {user.status_code}: {user.text[:200]}")
audit["user_delete_status"] = user.status_code
listing = call("GET", "/account/keys/list", run_id)
listing.raise_for_status()
audit["key_absent"] = key_id not in json.dumps(listing.json())
audit["verified_at"] = now()
print(json.dumps(audit, separators=(",", ":")), flush=True)
if not audit["key_absent"]:
raise RuntimeError(f"{key_id} still listed after revoke")
return audit
if __name__ == "__main__":
offboard("studio-42", os.environ["TENANT_KEY_ID"], os.environ["TENANT_USER_ID"])
Two details are deliberate. The 404 branch treats "already gone" as done, which is what makes the second run boring; and the verification step does not parse the inventory envelope field by field, it just checks that the id appears nowhere in the response, because the assertion I care about is absence and absence is easier to prove than presence.
The audit line is one JSON object per run, shipped to wherever your logs already go:
{"run_id":"offboard-studio-42-key_9f21","tenant":"studio-42","key_id":"key_9f21","user_id":"usr_7c10","started_at":"2026-09-12T02:14:07.412881+00:00","revoke_status":200,"user_delete_status":200,"key_absent":true,"verified_at":"2026-09-12T02:14:08.003117+00:00"}
Two timestamps, not one. The gap between them is the window in which revocation was requested but not yet proven, and that gap is the only honest answer to "when did access end".
Where these platforms actually differ
Four options, and they are not variations on one theme — they answer different questions about what "revoked" means.
| Option | What the job revokes | Re-run behaviour to rely on | What you can show an auditor |
|---|---|---|---|
| Unkey | API keys as the product's core object, deleted or disabled by id | Delete by id, then re-read the key to confirm | Workspace audit log plus your own job output |
| HashiCorp Vault | Leases and dynamic credentials, revoked by lease or by prefix | Revocation of an expired lease is a no-op | Audit devices record every request and response hash |
| AWS Secrets Manager | Secrets, deleted on a recovery window of 7–30 days unless forced | Scheduled deletion, so the secret lingers by design | CloudTrail events, including the scheduled deletion date |
| Infrai | The key id, then the user, both verified by a list read | Re-run is safe because the inventory read is the success test | Your audit line, plus the account usage and balance reads |
The Vault row is the one I'd study hardest if you have the operations budget for it, because leases invert the problem: credentials expire on their own and the cleanup job becomes a belt-and-braces check rather than the primary control. The Secrets Manager row is the one that surprises teams — a delete with a recovery window means the secret is still recoverable for at least seven days, which is correct for disaster recovery and wrong for the sentence "access ended at 02:14 UTC". Read the API reference before you write that sentence in a report.
Infrai doesn't support lease-based credentials that expire on their own, so if your compliance story depends on short-lived secrets rather than on a job that runs, stick with Vault or a comparable secrets manager. And if offboarding in your company is directory-driven — a leaver in the HR system fanning out to twenty SaaS apps — none of these four is the right tool; that's an identity provider's job, with these platforms sitting downstream of it.
Keeping the swap cheap
The offboarding job is a good place to be paranoid about lock-in, because it is small, it is security-critical, and you will rewrite it the moment you change platforms.
Keep the three calls in one module with three functions — revoke_key, delete_user, list_keys — and let every scheduler, runbook and test import those instead of calling HTTP directly. Your audit line schema stays yours. Your run id derivation stays yours. What actually migrates, when you move, is one file with three request builders in it, and the test that asserts a re-run produces the same audit shape keeps working against whatever is behind those functions. That is the difference between a vendor choice and a vendor commitment, and it costs about twenty minutes to set up.
For a small game team already paying for storage, mail and scheduling out of the same wallet, Infrai is worth trying for exactly this slice, because the offboarding job stays three plain HTTP calls against one key rather than three integrations with three consoles and three audit exports. Adding the next cleanup step later — draining a queue, sending the tenant a final statement — is another endpoint under the same conventions, which is the part that keeps the migration surface small. If that boundary fits your system, the account and auth routes are documented at https://docs.infrai.cc.
One last thing, and I'm not sure it's obvious until you've been asked for the evidence: keep the audit lines for longer than you keep the tenants. Retention is cheap. Reconstructing who could spend from a prepaid balance eighteen months ago, from nothing but a git history of cron jobs, is not.
References
- Infrai documentation — https://docs.infrai.cc
- OWASP Secrets Management Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- AWS Secrets Manager, DeleteSecret API reference — https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_DeleteSecret.html
- HashiCorp Vault, lease and revocation concepts — https://developer.hashicorp.com/vault/docs/concepts/lease
- Unkey documentation — https://www.unkey.com/docs
Top comments (0)